From 9582e96fb940f29c0e9c539549dc72f8789d7a1c Mon Sep 17 00:00:00 2001 From: laatonwalabhoot Date: Sun, 26 Nov 2023 11:18:54 +0530 Subject: [PATCH 1/5] initial multiplatform migration --- .editorconfig | 11 + .gitattributes | 3 + .gitignore | 25 + LICENSE.txt | 201 +++ README.md | 52 + build.gradle.kts | 6 + gradle.properties | 18 + gradle/libs.versions.toml | 31 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 160 +++ gradlew.bat | 90 ++ run | 3 + settings.gradle.kts | 18 + unfurl/build.gradle.kts | 76 ++ .../me/saket/unfurl/Platform.android.kt | 9 + .../kotlin/me/saket/unfurl/Platform.kt | 5 + .../kotlin/me/saket/unfurl/UnfurlLogger.kt | 22 + .../kotlin/me/saket/unfurl/UnfurlResult.kt | 24 + .../kotlin/me/saket/unfurl/Unfurler.kt | 51 + .../me/saket/unfurl/delegates/deprecations.kt | 15 + .../unfurl/delegates/html/deprecations.kt | 7 + .../kotlin/me/saket/unfurl/deprecations.kt | 49 + .../unfurl/extension/HtmlMetadataParser.kt | 106 ++ .../unfurl/extension/HtmlTagsBasedUnfurler.kt | 66 + .../unfurl/extension/UnfurlerExtension.kt | 15 + .../me/saket/unfurl/extension/UrlExtension.kt | 18 + .../me/saket/unfurl/internal/LruCache.kt | 152 +++ .../saket/unfurl/internal/NullableLruCache.kt | 23 + unfurl/src/commonTest/kotlin/UnfurlerTest.kt | 130 ++ .../resources/html_source_gitless.com.html | 1090 +++++++++++++++++ .../resources/html_source_instagram.com.html | 56 + .../resources/html_source_saket.me.html | 370 ++++++ .../kotlin/me/saket/unfurl/Platform.ios.kt | 9 + unfurl/src/jvmMain/kotlin/Platform.jvm.kt | 9 + 35 files changed, 2926 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100755 run create mode 100644 settings.gradle.kts create mode 100644 unfurl/build.gradle.kts create mode 100644 unfurl/src/androidMain/kotlin/me/saket/unfurl/Platform.android.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/Platform.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlLogger.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlResult.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/Unfurler.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/deprecations.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/html/deprecations.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/deprecations.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UrlExtension.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/LruCache.kt create mode 100644 unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/NullableLruCache.kt create mode 100644 unfurl/src/commonTest/kotlin/UnfurlerTest.kt create mode 100644 unfurl/src/commonTest/resources/html_source_gitless.com.html create mode 100644 unfurl/src/commonTest/resources/html_source_instagram.com.html create mode 100644 unfurl/src/commonTest/resources/html_source_saket.me.html create mode 100644 unfurl/src/iosMain/kotlin/me/saket/unfurl/Platform.ios.kt create mode 100644 unfurl/src/jvmMain/kotlin/Platform.jvm.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7b0e51f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +[*.{kt, kts}] +indent_size = 2 +insert_final_newline = true +max_line_length = 120 + +[*.xml] +insert_final_newline = true + +[*.gradle] +indent_size = 2 +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a7e6301 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Prevent GitHub from labeling this as an HTML library. +# Doc: https://github.com/github/linguist/blob/master/docs/overrides.md +unfurl/src/test/resources/* linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf3b3a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +build/ + +# Gradle files +.gradle/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# IntelliJ stuff +.idea/ + +# OS specific ignores +.DS_Store +*~ +*.swp diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5db00dc --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + 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 2019 Saket Narayan + + 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/README.md b/README.md new file mode 100644 index 0000000..ccf9fd2 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# unfurl + +`unfurl` is a kotlin library that generates link previews by extracting their social metadata. + +```kotlin +val unfurler = Unfurler() +println(unfurler.unfurl("https://saket.me/great-teams-merge-fast/")) + +UnfurlResult( + url = "https://saket.me/great-teams-merge-fast", + title = "Great teams merge fast", + description = "Observations from watching my team at Square produce stellar work while moving fast and not breaking things.", + favicon = "https://saket.me/wp-content/uploads/2022/03/cropped-saket-photo-180x180.jpg", + thumbnail = "https://saket.me/wp-content/uploads/2021/02/great_teams_merge_fast_cover.jpg" +) +``` + +`unfurl` is extensible. See [TweetUnfurler](https://github.com/saket/unfurl/blob/trunk/unfurl-social/src/main/kotlin/me/saket/unfurl/social/TweetUnfurler.kt) as an example for unfurling tweets that can't be HTML scraped. + +```kotlin +val unfurler = Unfurler( + extensions = listOf(TweetUnfurler(), ...) +) +``` +```groovy +implementation "me.saket.unfurl:unfurl:1.7.0" +implementation "me.saket.unfurl:unfurl-social:1.7.0" // For TweetUnfurler. +``` + +### cli +```bash +$ brew install saket/repo/unfurl +$ unfurl https://saket.me/great-teams-merge-fast +``` + +## License + +``` +Copyright 2022 Saket Narayan. + +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/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..588e460 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + //trick: for the same plugin versions in all sub-modules + alias(libs.plugins.androidLibrary).apply(false) + alias(libs.plugins.kotlinMultiplatform).apply(false) + alias(libs.plugins.dokka) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9d37c4c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +GROUP=me.saket.unfurl +VERSION_NAME=1.8.0-SNAPSHOT + +POM_URL=https://github.com/saket/unfurl +POM_SCM_URL=https://github.com/saket/unfurl +POM_SCM_CONNECTION=scm:git@github.com:saket/unfurl.git +POM_SCM_DEV_CONNECTION=scm:git@github.com:saket/unfurl.git + +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo + +POM_DEVELOPER_ID=saketme +POM_DEVELOPER_NAME=Saket Narayan + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..667b94f --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,31 @@ +[versions] +agp = "8.2.0-beta06" +kotlin = "1.9.20" +dokka = "1.9.10" +ksoup = "0.0.6" +vanniktect-publish = "0.25.3" +ktor = "2.3.6" + + +compileSdk = "34" +minSdk = "24" +libraryVersion = "1.8.0" + + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +vanniktectPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "vanniktect-publish" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c63077a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Nov 26 06:58:58 IST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/run b/run new file mode 100755 index 0000000..942150c --- /dev/null +++ b/run @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +./gradlew --quiet cli:installDist && ./cli/build/install/cli/bin/cli "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a1fa465 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "unfurl-kmp" +include(":unfurl") \ No newline at end of file diff --git a/unfurl/build.gradle.kts b/unfurl/build.gradle.kts new file mode 100644 index 0000000..788a134 --- /dev/null +++ b/unfurl/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.dokka) + id("maven-publish") +} + +group = "me.saket.unfurl" +version = libs.versions.libraryVersion.get() + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + explicitApi() + + jvm() + + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "unfurl" + isStatic = true + } + } + + sourceSets { + sourceSets { + commonMain.dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ksoup) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.ktor.client.mock) + } + + jvmMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + + jvmTest.dependencies { + implementation(libs.kotlin.test) + } + + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + } + } +} + +android { + namespace = "me.saket.unfurl" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/unfurl/src/androidMain/kotlin/me/saket/unfurl/Platform.android.kt b/unfurl/src/androidMain/kotlin/me/saket/unfurl/Platform.android.kt new file mode 100644 index 0000000..c5cc3d4 --- /dev/null +++ b/unfurl/src/androidMain/kotlin/me/saket/unfurl/Platform.android.kt @@ -0,0 +1,9 @@ +package me.saket.unfurl + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.okhttp.OkHttp + +internal actual fun provideHttpClientEngine(): HttpClientEngine { + return OkHttp.create { + } +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/Platform.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/Platform.kt new file mode 100644 index 0000000..b628aa3 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/Platform.kt @@ -0,0 +1,5 @@ +package me.saket.unfurl + +import io.ktor.client.engine.HttpClientEngine + +internal expect fun provideHttpClientEngine(): HttpClientEngine diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlLogger.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlLogger.kt new file mode 100644 index 0000000..87b7d57 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlLogger.kt @@ -0,0 +1,22 @@ +package me.saket.unfurl + +public interface UnfurlLogger { + public fun log(message: String) + public fun log(e: Throwable, message: String) + + public object Println : UnfurlLogger { + override fun log(e: Throwable, message: String) { + println(message) + println(e.stackTraceToString()) + } + + override fun log(message: String) { + println(message) + } + } + + public object NoOp : UnfurlLogger { + override fun log(message: String): Unit = Unit + override fun log(e: Throwable, message: String): Unit = Unit + } +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlResult.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlResult.kt new file mode 100644 index 0000000..e5af523 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/UnfurlResult.kt @@ -0,0 +1,24 @@ +package me.saket.unfurl + +import io.ktor.http.Url + +/** + * @param url May or may not be equal to the original URL used with [Unfurler.unfurl]. + * This can happen in situations where HTTP 3xx redirects are followed. For example, + * `https://youtu.be/foo` will redirect to `https://www.youtube.com/watch?v=foo`. + */ +public data class UnfurlResult( + val url: Url, + val title: String?, + val description: String?, + val favicon: Url?, + val thumbnail: Url?, + val contentPreview: ContentPreview? = null, +) { + + /** + * Additional metadata that can be populated by extensions. + * See `TweetContentPreview` for an example. + */ + public interface ContentPreview +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/Unfurler.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/Unfurler.kt new file mode 100644 index 0000000..f34937b --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/Unfurler.kt @@ -0,0 +1,51 @@ +package me.saket.unfurl + +import io.ktor.client.HttpClient +import io.ktor.http.Url +import me.saket.unfurl.extension.HtmlTagsBasedUnfurler +import me.saket.unfurl.extension.UnfurlerExtension +import me.saket.unfurl.extension.UnfurlerScope +import me.saket.unfurl.extension.toHttpUrlOrNull +import me.saket.unfurl.internal.NullableLruCache + +public class Unfurler( + cacheSize: Int = 100, + extensions: List = emptyList(), + public val httpClient: HttpClient = defaultHttpClient(), + public val logger: UnfurlLogger = UnfurlLogger.Println, +) { + private val extensions = extensions + HtmlTagsBasedUnfurler() + private val cache = NullableLruCache(cacheSize) + + private val extensionScope = object : UnfurlerScope { + override val httpClient: HttpClient get() = this@Unfurler.httpClient + override val logger: UnfurlLogger get() = this@Unfurler.logger + } + + public fun unfurl(url: String): UnfurlResult? { + return cache.computeIfAbsent(url) { + try { + url.toHttpUrlOrNull()?.let { httpUrl -> + extensions.asSequence() + .mapNotNull { it.run { extensionScope.unfurl(httpUrl) } } + .firstOrNull() + } + } catch (e: Throwable) { + logger.log(e, "Failed to unfurl '$url'") + null + } + } + } + + public fun unfurl(url: Url): UnfurlResult? { + return unfurl(url.toString()) + } + + public companion object { + public fun defaultHttpClient(): HttpClient { + return HttpClient(provideHttpClientEngine()) { + followRedirects = true + } + } + } +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/deprecations.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/deprecations.kt new file mode 100644 index 0000000..3fc9a4b --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/deprecations.kt @@ -0,0 +1,15 @@ +package me.saket.unfurl.delegates + +import me.saket.unfurl.extension.UnfurlerExtension + +@Deprecated( + message = "Renamed to UnfurlerExtension", + replaceWith = ReplaceWith("me.saket.unfurl.extension.UnfurlerExtension") +) +public interface UnfurlerDelegate : UnfurlerExtension + +@Deprecated( + message = "Renamed to UnfurlerExtensionScope", + replaceWith = ReplaceWith("me.saket.unfurl.extension.UnfurlerScope") +) +public typealias UnfurlerDelegateScope = me.saket.unfurl.extension.UnfurlerScope diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/html/deprecations.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/html/deprecations.kt new file mode 100644 index 0000000..f99f7f3 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/delegates/html/deprecations.kt @@ -0,0 +1,7 @@ +package me.saket.unfurl.delegates.html + +@Deprecated( + message = "Renamed to HtmlTagsBasedUnfurler", + replaceWith = ReplaceWith("me.saket.unfurl.extension.HtmlTagsBasedUnfurler") +) +public typealias HtmlTagsBasedUnfurler = me.saket.unfurl.extension.HtmlTagsBasedUnfurler diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/deprecations.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/deprecations.kt new file mode 100644 index 0000000..76d7b93 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/deprecations.kt @@ -0,0 +1,49 @@ +package me.saket.unfurl + +import io.ktor.client.HttpClient +import me.saket.unfurl.delegates.UnfurlerDelegate + +@Deprecated( + message = """"delegates" parameter has been renamed to "extensions"""", + replaceWith = ReplaceWith( + "Unfurler(cacheSize = cacheSize, extensions = delegates, httpClient = httpClient)", + "me.saket.unfurl.Unfurler" + ), +) +public fun Unfurler( + cacheSize: Int = 100, + delegates: List, + httpClient: HttpClient = Unfurler.defaultHttpClient(), +): Unfurler = Unfurler( + cacheSize = cacheSize, + extensions = delegates, + httpClient = httpClient, +) + +@Deprecated( + message = """"delegates" parameter has been renamed to "extensions"""", + replaceWith = ReplaceWith( + "Unfurler(cacheSize = cacheSize, extensions = delegates)", + "me.saket.unfurl.Unfurler" + ), +) +public fun Unfurler( + cacheSize: Int = 100, + delegates: List, +): Unfurler = Unfurler( + cacheSize = cacheSize, + extensions = delegates, +) + +@Deprecated( + message = """"delegates" parameter has been renamed to "extensions"""", + replaceWith = ReplaceWith( + "Unfurler(extensions = delegates)", + "me.saket.unfurl.Unfurler" + ), +) +public fun Unfurler( + delegates: List +): Unfurler = Unfurler( + extensions = delegates, +) diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt new file mode 100644 index 0000000..631c6c6 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt @@ -0,0 +1,106 @@ +package me.saket.unfurl.extension + +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.appendEncodedPathSegments +import me.saket.unfurl.UnfurlLogger +import me.saket.unfurl.UnfurlResult + +internal class HtmlMetadataParser(private val logger: UnfurlLogger) { + + fun parse(url: Url, document: Document): UnfurlResult { + return UnfurlResult( + url = url, + title = parseTitle(document), + description = parseDescription(document), + favicon = parseFaviconUrl(document) ?: fallbackFaviconUrl(url), + thumbnail = parseThumbnailUrl(document) + ) + } + + private fun parseTitle(document: Document): String? { + val linkTitle = metaTag(document, "twitter:title") + ?: metaTag(document, "og:title") + ?: document.title().nullIfBlank() + + if (linkTitle == null) { + logger.log("couldn't find any title for ${document.baseUri()}.") + } + return linkTitle + } + + private fun parseDescription(document: Document): String? { + val linkTitle = metaTag(document, "twitter:description") + ?: metaTag(document, "og:description") + ?: metaTag(document, "description") + + if (linkTitle == null) { + logger.log("couldn't find any description for ${document.baseUri()}.") + } + return linkTitle + } + + private fun parseThumbnailUrl(document: Document): Url? { + // Twitter's image tag is preferred over facebook's + // because websites seem to give better images for twitter. + val thumbnailUrl = metaTag(document, "twitter:image", isUrl = true) + ?: metaTag(document, "og:image", isUrl = true) + ?: metaTag(document, "twitter:image:src", isUrl = true) + ?: metaTag(document, "og:image:secure_url", isUrl = true) + + // So... scheme-less URLs are a thing. + val needsScheme = thumbnailUrl != null && thumbnailUrl.startsWith("//") + return (if (needsScheme) "https:$thumbnailUrl" else thumbnailUrl)?.toHttpUrlOrNull() + } + + private fun parseFaviconUrl(document: Document): Url? { + val faviconUrl = linkRelTag(document, "apple-touch-icon") + ?: linkRelTag(document, "apple-touch-icon-precomposed") + ?: linkRelTag(document, "shortcut icon") + ?: linkRelTag(document, "icon") + return faviconUrl?.toHttpUrlOrNull() + } + + private fun fallbackFaviconUrl(url: Url): Url { + return URLBuilder(protocol = url.protocol, host = url.host) + .appendEncodedPathSegments("/favicon.ico") + .build() + } + + private fun metaTag(document: Document, attr: String, isUrl: Boolean = false): String? { + val names = document.select("meta[name=$attr]") + val properties = document.select("meta[property=$attr]") + + return sequenceOf(names, properties) + .flatMap { it } + .mapNotNull { element: Element -> + element.attr(if (isUrl) "abs:content" else "content").nullIfBlank() + } + .firstOrNull() + } + + private fun linkRelTag(document: Document, rel: String): String? { + val elements = document.head().select("link[rel=$rel]") + var largestSizeUrl = elements.firstOrNull()?.attr("abs:href") ?: return null + var largestSize = 0 + + for (element in elements) { + // Some websites have multiple icons for different sizes. Find the largest one. + val sizes = element.attr("sizes") + if (sizes.contains("x")) { + val size = sizes.split("x")[0].toInt() + if (size > largestSize) { + largestSize = size + largestSizeUrl = element.attr("abs:href") + } + } + } + return largestSizeUrl + } +} + +private fun String.nullIfBlank(): String? { + return ifBlank { null } +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt new file mode 100644 index 0000000..1432882 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt @@ -0,0 +1,66 @@ +package me.saket.unfurl.extension + +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.ported.BufferReader +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.url +import io.ktor.client.statement.readBytes +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.utils.io.core.use +import kotlinx.coroutines.runBlocking +import me.saket.unfurl.UnfurlResult + +public open class HtmlTagsBasedUnfurler : UnfurlerExtension { + override fun UnfurlerScope.unfurl(url: Url): UnfurlResult? { + return downloadHtml(url)?.let { doc -> + extractMetadata(doc) + } + } + + private fun UnfurlerScope.downloadHtml(url: Url): Document? { + return try { + httpClient.use { + runBlocking { + val response = it.request { + header( + "User-Agent", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36" + ) + url(url) + build() + } + + val contentType = response.contentType() + val redirectedUrl = response.request.url + + if (contentType.isHtmlText()) { + Ksoup.parse( + /* in */ BufferReader(response.readBytes()), + /* charsetName */ null, + /* baseUri */ redirectedUrl.toString() + ) + } else { + null + } + } + } + } catch (e: Throwable) { + logger.log(e, "Failed to download HTML for $url") + null + } + } + + public open fun UnfurlerScope.extractMetadata(document: Document): UnfurlResult? { + val parser = HtmlMetadataParser(logger) + return parser.parse(url = document.baseUri().toUrl(), document = document) + } + + private fun ContentType?.isHtmlText(): Boolean { + return this != null && contentType == "text" && contentSubtype == "html" + } +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt new file mode 100644 index 0000000..147d313 --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt @@ -0,0 +1,15 @@ +package me.saket.unfurl.extension + +import io.ktor.client.HttpClient +import io.ktor.http.Url +import me.saket.unfurl.UnfurlLogger +import me.saket.unfurl.UnfurlResult + +public interface UnfurlerExtension { + public fun UnfurlerScope.unfurl(url: Url): UnfurlResult? +} + +public interface UnfurlerScope { + public val httpClient: HttpClient + public val logger: UnfurlLogger +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UrlExtension.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UrlExtension.kt new file mode 100644 index 0000000..953011b --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/extension/UrlExtension.kt @@ -0,0 +1,18 @@ +package me.saket.unfurl.extension + +import io.ktor.http.URLBuilder +import io.ktor.http.Url + +public fun String.toHttpUrlOrNull(): Url? { + return try { + Url(this) + } catch (_: IllegalArgumentException) { + null + } +} + +public fun String.toUrl(): Url = URLBuilder(this).build() + + + + diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/LruCache.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/LruCache.kt new file mode 100644 index 0000000..795825e --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/LruCache.kt @@ -0,0 +1,152 @@ +package me.saket.unfurl.internal + +internal typealias Weigher = (K, V?) -> Int + +/** + * Multiplatform LRU cache implementation. + * + * Implementation is based on usage of [LinkedHashMap] as a container for the cache and custom + * double linked queue to track LRU property. + * + * [maxSize] - maximum size of the cache, can be anything bytes, number of entries etc. By default is number o entries. + * [weigher] - to be called to calculate the estimated size (weight) of the cache entry defined by its [K] and [V]. + * By default it returns 1. + * + * Cache trim performed only on new entry insertion. + */ +internal class LruCache( + private val maxSize: Int, + private val weigher: Weigher = { _, _ -> 1 } +) { + private val cache = LinkedHashMap>(0, 0.75f) + private var headNode: Node? = null + private var tailNode: Node? = null + private var size: Int = 0 + + operator fun get(key: K): V? { + val node = cache[key] + if (node != null) { + moveNodeToHead(node) + } + return node?.value + } + + operator fun set(key: K, value: V) { + val node = cache[key] + if (node == null) { + cache[key] = addNode(key, value) + } else { + node.value = value + moveNodeToHead(node) + } + + trim() + } + + fun remove(key: K): V? { + return removeUnsafe(key) + } + + fun keys() = cache.keys + + private fun removeUnsafe(key: K): V? { + val nodeToRemove = cache.remove(key) + val value = nodeToRemove?.value + if (nodeToRemove != null) { + unlinkNode(nodeToRemove) + } + return value + } + + fun remove(keys: Collection) { + keys.forEach { key -> removeUnsafe(key) } + } + + fun clear() { + cache.clear() + headNode = null + tailNode = null + size = 0 + } + + fun size(): Int { + return size + } + + fun dump(): Map { + return cache.mapValues { (_, value) -> value.value as V } + } + + private fun trim() { + var nodeToRemove = tailNode + while (nodeToRemove != null && size > maxSize) { + cache.remove(nodeToRemove.key) + unlinkNode(nodeToRemove) + nodeToRemove = tailNode + } + } + + private fun addNode(key: K, value: V?): Node { + val node = Node( + key = key, + value = value, + next = headNode, + prev = null, + ) + + headNode = node + + if (node.next == null) { + tailNode = headNode + } else { + node.next?.prev = headNode + } + + size += weigher(key, value) + + return node + } + + private fun moveNodeToHead(node: Node) { + if (node.prev == null) { + return + } + + node.prev?.next = node.next + node.next?.prev = node.prev + + node.next = headNode?.next + node.prev = null + + headNode?.prev = node + headNode = node + } + + private fun unlinkNode(node: Node) { + if (node.prev == null) { + this.headNode = node.next + } else { + node.prev?.next = node.next + } + + if (node.next == null) { + this.tailNode = node.prev + } else { + node.next?.prev = node.prev + } + + size -= weigher(node.key!!, node.value) + + node.key = null + node.value = null + node.next = null + node.prev = null + } + + private class Node( + var key: K?, + var value: V?, + var next: Node?, + var prev: Node?, + ) +} diff --git a/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/NullableLruCache.kt b/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/NullableLruCache.kt new file mode 100644 index 0000000..370f97b --- /dev/null +++ b/unfurl/src/commonMain/kotlin/me/saket/unfurl/internal/NullableLruCache.kt @@ -0,0 +1,23 @@ +package me.saket.unfurl.internal + +import me.saket.unfurl.internal.NullableLruCache.Optional.None +import me.saket.unfurl.internal.NullableLruCache.Optional.Some + +internal class NullableLruCache(maxSize: Int) { + private val delegate = LruCache>(maxSize) + + inline fun computeIfAbsent(key: K, create: () -> V?): V? { + return when (val cached = delegate[key]) { + is Some -> cached.value + is None -> null + null -> create().also { + delegate[key] = if (it == null) None else Some(it) + } + } + } + + sealed class Optional { + data class Some(val value: T) : Optional() + data object None : Optional() + } +} diff --git a/unfurl/src/commonTest/kotlin/UnfurlerTest.kt b/unfurl/src/commonTest/kotlin/UnfurlerTest.kt new file mode 100644 index 0000000..bc9e670 --- /dev/null +++ b/unfurl/src/commonTest/kotlin/UnfurlerTest.kt @@ -0,0 +1,130 @@ +package me.saket.unfurl + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import io.ktor.utils.io.core.use +import me.saket.unfurl.extension.toUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + + +class UnfurlerTest { + private val unfurler = Unfurler() + + @Test + fun `parse HTML correctly`() { + parameterizedTest(enumValues()) { input -> + val mockEngine = MockEngine.invoke { + respond( + content = readResourceFile(input.htmlFileName), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/html; charset=UTF-8") + ) + } + val server = HttpClient(mockEngine) + server.use { + val localUrl = input.url.toUrl() + val result = unfurler.unfurl(localUrl) + assertEquals(result, input.expected("".toUrl())?.copy(url = localUrl)) + // TODO: Clear server request queue + } + } + } + + @Test + fun `websites that deny requests without a recognizable user-agent`() { + val result = unfurler.unfurl("https://www.getproactiv.ca/pdp?productcode=842944100695") + assertEquals( + result, UnfurlResult( + url = "https://www.getproactiv.ca/pdp?productcode=842944100695".toUrl(), + title = "Proactiv Solution® Repairing Treatment | Proactiv® Products", + description = "Our Repairing Treatment is a leave-on treatment formulated with prescription-grade benzoyl peroxide designed to penetrate pores to kill acne-causing bacteria.", + favicon = "https://www.getproactiv.ca/favicon.ico".toUrl(), + thumbnail = "https://cdn-tp3.mozu.com/30113-50629/cms/50629/files/f050a010-0420-4a53-b898-d4c08db77eb9".toUrl(), + ) + ) + } + + @Test + fun `follow redirects`() { + val mockEngine = MockEngine.invoke { + respond( + content = "", + status = HttpStatusCode.SeeOther, + headers = headersOf(HttpHeaders.Location, "https://www.youtube.com/watch?v=o-YBDTqX_ZU&feature=youtu.be") + ) + } + val server = HttpClient(mockEngine) + server.use { + val result = unfurler.unfurl("/youtu.be/o-YBDTqX_ZU".toUrl()) + assertNotNull(result) + // TODO: Clear server request queue + } + } +} + +@Suppress("EnumEntryName", "unused") +enum class HtmlTestInput( + val url: String, + val htmlFileName: String, + val expected: (serverUrl: Url) -> UnfurlResult? +) { + Saket_me( // Uses both OGP and twitter meta tags. + url = "/saket.me/great-teams-merge-fast/", + htmlFileName = "html_source_saket.me.html", + expected = { + UnfurlResult( + url = "https://saket.me/great-teams-merge-fast/".toUrl(), + title = "Great teams merge fast", + description = "Observations from watching my team at Square produce stellar work while moving fast and not breaking things.", + thumbnail = "https://saket.me/wp-content/uploads/2021/02/great_teams_merge_fast_cover.jpg".toUrl(), + favicon = "https://saket.me/wp-content/uploads/2022/03/cropped-saket-photo-180x180.jpg".toUrl(), + ) + } + ), + Instagram_com( // Does not use most twitter meta tags. + url = "/about.instagram.com", + htmlFileName = "html_source_instagram.com.html", + expected = { + UnfurlResult( + url = "https://about.instagram.com/".toUrl(), + title = "About Instagram's Official Site", + description = "We strive to bring people together in a safe and supportive community. We believe expression is the greatest connector. Make the most of your Instagram experience!", + thumbnail = "https://scontent-ort2-2.xx.fbcdn.net/v/t39.2365-6/75883158_790065824784383_3063578611500974080_n.jpg?_nc_cat=109&ccb=1-7&_nc_sid=ad8a9d&_nc_ohc=AIIVl_p_K0gAX9iad2X&_nc_ht=scontent-ort2-2.xx&oh=00_AT-0skyUQDSPTdFriyws79pzJ1z1Z9geycC9kp-dq4Mw3A&oe=62F7BDFE".toUrl(), + favicon = "https://static.xx.fbcdn.net/rsrc.php/v3/yw/r/HTE9u6HBvgx.png".toUrl(), + ) + } + ), + Gitless_com( // Does not use any social tags. + url = "/gitless.com", + htmlFileName = "html_source_gitless.com.html", + expected = { serverUrl -> + UnfurlResult( + url = "https://gitless.com".toUrl(), + title = "Gitless", + description = "Gitless: a simple version control system built on top of Git", + thumbnail = null, + favicon = "${serverUrl}favicon.ico".toUrl(), + ) + } + ) +} + +fun readResourceFile(fileName: String): String { + // TODO: Read from resources to run tests + return "" +// val url = Thread.currentThread().contextClassLoader.getResource(fileName)!! +// return File(url.path).readText() +} + +fun parameterizedTest(parameters: Array, testFunc: (T) -> Unit) { + parameters.forEach { + testFunc(it) + } +} diff --git a/unfurl/src/commonTest/resources/html_source_gitless.com.html b/unfurl/src/commonTest/resources/html_source_gitless.com.html new file mode 100644 index 0000000..ab15b13 --- /dev/null +++ b/unfurl/src/commonTest/resources/html_source_gitless.com.html @@ -0,0 +1,1090 @@ + + + + + + + Gitless + + + + + + + + + + + + + +

+ + + + + +
+

About

+

+ Gitless is a Git-compatible version control system, that is + easy to learn and use: +

+
    +
  • + Simple commit workflow
    + Track or untrack files to control + what changes to commit. Changes to tracked files are + committed by default, but you can easily customize the set of files to + commit using flags +
  • +
  • + Independent branches
    + Branches in Gitless include your working changes, so you + can switch between branches without having to worry about conflicting + uncommitted changes +
  • +
  • + Friendly command-line interface
    + Gitless commands will give you good feedback and help you figure out + what to do next +
  • +
  • + Compatible with Git
    + Because Gitless is implemented on top of Git, you can always fall back + on Git. And your coworkers you share a repo with need never + know that you're not a Git aficionado. Moreover, you can use Gitless + with GitHub or with any Git hosting service +
  • +
+
+ + + +
+

Documentation

+ + + +

Interface

+
    +
  • + gl init + - create an empty repo or create one from an existing remote + repo +
  • +
  • + gl status + - show status of the repo +
  • +
  • + gl track + - start tracking changes to files +
  • +
  • + gl untrack + - stop tracking changes to files +
  • +
  • + gl diff + - show changes to files +
  • + gl commit + - record changes in the local repo +
  • +
  • + gl checkout + - checkout committed versions of files +
  • +
  • + gl history + - show commit history +
  • +
  • + gl branch + - list, create, edit or delete branches +
  • +
  • + gl switch + - switch branches +
  • +
  • + gl tag + - list, create, or delete tags +
  • +
  • + gl merge + - merge the divergent changes of one branch onto another +
  • +
  • + gl fuse + - fuse the divergent changes of one branch onto another +
  • +
  • + gl resolve + - mark files with conflicts as resolved +
  • +
  • + gl publish + - publish commits upstream +
  • +
  • + gl remote + - list, create, edit or delete remotes +
  • +
+ + + +

Guide

+

+ creating a repository | + saving changes | + branching | + tagging | + working with remote repositories +

+

Creating a Repository

+

+ Say you are in directory foo and you want turn it into a + repository. You do this with the + gl init command. This transforms + the current working directory into an empty repository and you + are now ready to start saving changes to files in foo: +

+
+
$ mkdir foo
+$ cd foo/
+$ gl init
+✔ Local repo created in /MyFiles/foo
+
+

+ In most cases, instead of starting with an empty repository, there's already + some existing remote repository you want to work on. To clone a remote repository, + you pass the URL of the repository to the same gl init command, as + shown below (note that you'll have + to replace https://github.com/gitless-vcs/try-gitless with + the URL of your repo): +

+
+
$ mkdir try-gitless
+$ cd try-gitless/
+$ gl init https://github.com/gitless-vcs/try-gitless
+✔ Local repo created in /MyFiles/try-gitless
+✔ Initialized from remote https://github.com/gitless-vcs/try-gitless
+
+

+ If you don't have a remote repository yet, you can follow + these instructions to create one on + GitHub. +

+ +

Saving Changes

+

+ Now that you have your local repository, it's time to start saving + changes to files. A file in Gitless can be tracked, + untracked or ignored. + A tracked file is a file whose changes Gitless will detect. + Tracked files are automatically considered for commit if they are + modified and appear listed under the "Tracked files with modifications" + section of status. + Conversely, an untracked file is a file whose changes Gitless + will not detect. These are not automatically considered for commit and + appear listed under the "Untracked files" section of + status. + Finally, an ignored file is a file that is completely ignored by + Gitless and it won't even appear in the output of status. +

+

+ An example output of the gl status + command + (foo.py and bar.py are tracked files + with modifications, .gitignore is an unmodified tracked file, + baz.py is an untracked file and foo.pyc is an + ignored file): +

+
+
$ ls
+bar.py  baz.py  foo.py  foo.pyc .gitignore
+$ gl status
+On branch master, repo-directory //
+
+Tracked files with modifications:
+  ➜ these will be automatically considered for commit
+  ➜ use gl untrack <f> if you don't want to track changes to file f
+  ➜ if file f was committed before, use gl checkout <f> to discard local changes
+
+    foo.py
+    bar.py
+
+Untracked files:
+  ➜ these won't be considered for commit)
+  ➜ use gl track <f> if you want to track changes to file f
+
+    baz.py
+
+

+ Now, how do files move between these three different disjoint + states? +

+

+ A file is ignored if it's matched by the ignore specification described in + a .gitignore file. + In the example above, + there is a .gitignore file whose content is '*.pyc'; since + foo.pyc is matched by that pattern it's therefore an + ignored file. +

+

+ A new file that is not matched by the ignore spec is initially an untracked + file. If you want to track it you can do so with the + gl track command. You can stop tracking + changes to a tracked file + with the gl untrack command. + You can always revert a file back to some previous version with the + gl checkout command. +

+
+
$ gl track baz.py
+✔ File baz.py is now a tracked file
+$ gl track baz.py
+✘ File baz.py is already a tracked file
+$ gl untrack baz.py
+✔ File baz.py is now an untracked file
+$ gl checkout foo.py
+You have uncommitted changes in foo.py that would be
+overwritten by checkout. Do you wish to continue? (y/N)
+> y
+✔ File foo.py checked out successfully to its state at HEAD
+
+

+ To save changes to files you use + gl commit. + By default, all tracked modified files are considered + for commit, but the set of files to commit can be customized by listing + the files to be committed only, or using the e/exclude and i/include + flags: +

+
+
$ gl commit -m "foo and bar"
+$ gl commit -m "only foo" foo.py
+$ gl commit -m "only foo and baz" foo.py baz.py
+$ gl commit -m "only foo" -e bar.py
+$ gl commit -m "only foo and baz" -e bar.py -i baz.py
+$ gl commit -m "foo, bar and baz" -i baz.py
+

+ There's also a p/partial flag that allows you to + interactively select segments of files to commit. +

+

+ The gl diff command can be used to see the + difference between the working and committed versions of files. Like + commit, the default set of files to diff is + the set of all tracked modified files but it can be customized by + listing files, or using the e/exclude and i/include + flags. +

+

+ To remove files simply do it like you would in + your operating system (e.g., using Unix's rm command). Gitless + will detect the change if the file was tracked, and it will appear as removed + in status. Gitless currently doesn't detect renames. If you rename a file, + Gitless will interpret this as a file with the old name being removed and a new one + (with the new name and content) being created. If you want the renamed file to be tracked + again you need to track it with gl track. +

+ + +

Branching

+

+ A branch is an independent line of development. You are always working + on some branch. Each branch has its own history (which you can look at + with the gl history command). Any changes to + existing files or new files you create on a branch will not be present on + the other branch when you switch branches. +

+

To create a new branch you use the + gl branch + command. To switch to another branch you use the + gl switch command: +

+
+
$ gl branch -c develop
+✔ Created new branch develop
+$ gl switch develop
+✔ Switched to branch develop
+
+
+

+ To list all branches: +

+
+
$ gl branch
+List of branches:
+  ➜ do gl branch <b> to create branch b
+  ➜ do gl branch -d <b> to delete branch b
+  ➜ do gl switch <b> to switch to branch b
+  ➜ * = current branch
+
+    * master
+      develop
+
+
+ +

+ Each branch has a head, which is the last commit done on the branch. + By default, the head of a new branch is going to be equal to the + head of the current branch. If you want a different commit to be the + head of the new branch you can provide one with the + dp/divergent-point flag. +

+

+ To specify a commit you can use its id, or you can specify it via an ancestry + reference with ~: HEAD~n refers to + the nth commit before head. +

+

+ To change the head of the current branch you use the sh/set-head flag. + The sh flag is useful to, for example, amend the last commit: + to do so, run gl branch -sh HEAD~1. + Changing the head of the current branch won't touch your working directory, if you + additionally want to reset your working directory to match the new head + you use gl checkout. +

+

+ Eventually branches will end up having divergent + changes. There are two ways to bring changes from one branch onto the + current branch: merge and fuse. +

+

+ Merging branches. + For merging the changes in develop onto the current branch + you do gl merge develop. This creates + a new merge commit that includes the changes in develop in + addition to those changes in the current branch: +

+
+ merge +
+ + +

+ Fusing branches. Fusing branches gives you more control than + merging. When you fuse changes from some branch onto the + current branch you can specify the commits to fuse and the insertion + point. By default, all divergent commits are fused and the insertion + point is the divergent point (the point where the source branch diverged + from the current). + For example, the following figure depicts a situation in which there are + two branches: master (the current branch) and + develop. The last commit these two branches have in common is + A. This commit is the "divergent point" (because + it is the point at which master and develop + diverged). + After doing gl fuse develop, the commits + in develop are inserted in master after the + divergent point: +

+
+ fuse +
+

+ To choose other insertion points you use the + ip/insertion-point flag. You can give a commit id as input, + head or dp/divergent-point: +

+
+ fuse with insertion point specified +
+

+ The o/only and e/exclude flags can be used to + customize the set of commits to be fused: +

+
+ fuse with insertion point and commit specified +
+

+ During this process conflicts could occur. If so, the gl status command will change + accordingly to indicate the files in conflict. Once you edit those files in + conflict you mark them as resolved with + gl resolve (passing the files to mark as input). Once all + conflicts have been resolved + you do gl commit to commit to continue with the fuse or + merge. +

+

+ A branch can have an "upstream branch." If a branch has an upstream + associated with it, then gl fuse or gl merge can + be used as shorthands for gl {fuse, merge} upstream_branch. + To set an upstream branch for the current branch use + gl branch -su upstream_branch. +

+ + +

Tagging

+

+ You use tags to signify a commit is special in some way. For example, you + can use gl tag to create a tag with name + "v1.0" and make it point to the commit that represents release v1.0: +

+
+
$ gl tag -c v1.0
+✔ Created new tag v1.0
+
+
+

In this case, the tag will point to the head of the current branch, but + you can tag other commits with the ci/commit flag. +

+

+ To list all tags: +

+
+
$ gl tag
+List of tags:
+  ➜ do gl tag <t> to create tag t
+  ➜ do gl tag -d <t> to delete tag t
+
+    v1.0 ➜  tags 311bf7c Ready to release
+
+
+ + +

Working with Remote Repositories

+

+ To refer to a remote repository you could always use its URL, but an + easier alternative is to add the repository as a "remote" with the + gl remote command: +

+
$ gl remote -c try-gitless https://github.com/gitless-vcs/try-gitless
+✔ Remote try-gitless mapping to https://github.com/gitless-vcs/try-gitless created
+successfully
+  ➜ to list existing remotes do gl remote
+  ➜ to remove try-gitless do gl remote -d try-gitless
+
+

+ Now you can use try-gitless to refer to this remote repository + and use try-gitless/some-branch to refer to the branch of name + some-branch that lives in try-gitless. +

+

+ Downloading Changes. + It is also possible to fuse or merge changes from remote + branches. For example, doing gl merge try-gitless/master + would merge changes in that remote branch that are not present in your + local current branch. You can also use gl fuse. +

+

+ Uploading Changes. + To send changes upstream you use + gl publish. The publish command will + default to updating the upstream branch of the current branch if none is + given as input. +

+

+ Creating, Deleting, or Listing Remote Branches. + To create, delete, or list remote branches you use the same gl branch + command that you use for local branches. If you do + gl branch -c try-gitless/develop this will create a branch + develop that lives in the remote try-gitless. + Recall that, by default, the head of this new branch will be equal to the head of + the current branch, so all commits that are not present in the remote + will be uploaded. To list remote branches use the + r/remote flag of gl branch. +

+

+ Creating, Deleting, or Listing Remote Tags. + To create or delete remote tags you use the gl tag command. + If you do + gl tag -c try-gitless/v1.0 this will create a new tag + v1.0 that lives in the remote try-gitless. You can also list + remote tags with the r/remote flag of gl tag. +

+ +

+ When you create a local repository from a remote (by passing a URL as + input to the gl init command), a local branch is created + for each remote branch, and each local branch is automatically configured + to have as upstream its remote counterpart. +

+ + + +
+ + + +
+

Gitless vs. Git

+ + +

Saving Changes

+

There's no staging area in Gitless. This, coupled with a flexible + commit command makes saving changes to the repository very + straightforward: +

+ +
+
Commit all modified tracked files
+$ gl commit
+The default set of files to be committed are all
+modified tracked files
+
+Leave some modified tracked files (`foo`, `bar`)
+out of the commit
+$ gl commit -e foo bar
+e/exclude excludes files from the default set
+of files to be committed
+
+Include some untracked files in the commit
+$ gl commit -i foo2 bar2
+i/include includes files to the default set
+of files to be committed
+
+Commit only some of the modified tracked files
+$ gl commit foo3 bar3
+listing files restricts the set of files to be committed
+to only the specified ones
+
+Commit only some of the modified tracked or
+untracked files
+$ gl commit foo3 bar3 foo4
+
+
+
+Commit all modified tracked files
+$ git commit -a
+-a/--all automatically stages files that have been
+modified or deleted
+
+Leave some modified tracked files (`foo`, `bar`)
+out of the commit
+$ git add all files you want to commit minus foo bar
+$ git commit
+
+
+Include some untracked files in the commit
+$ git add foo2 bar2 + other files you want to commit
+$ git commit
+
+
+Commit only some of the modified tracked files
+$ git commit foo3 bar3
+
+
+
+Commit only some of the modified tracked or
+untracked files
+$ git commit foo3 bar3 foo4
+error: pathspec 'foo4' did not match any file(s)
+known to git.
+...hmm ok... 
+$ git add foo4
+$ git commit foo3 bar3 foo4
+
+
+

Also, you can change the classification of any file to tracked, + untracked or ignored, it doesn't matter whether the file exists at head + or not: +

+
+
$ gl status
+...
+Tracked files with modifications:
+...
+  foo
+...
+
+Stop tracking changes to `foo`
+$ gl untrack foo
+✔ File foo is now an untracked file
+Now `foo` won't be automatically considered for
+commit
+$ gl status
+...
+Untracked files:
+...
+  foo (exists at head)
+...
+
+Start tracking changes to `foo` again
+$ gl track foo
+✔ File foo is now a tracked file
+Now `foo` will be automatically considered for commit
+$ gl status
+...
+Tracked files with modifications:
+...
+  foo
+...
+
+
$ git status
+...
+Changes not staged for commit:
+...
+  modified: foo
+...
+
+Stop tracking changes to `foo`
+No way to do this, if you want to prevent the
+accidental commit of `foo` you can do `git add` of
+all files but `foo` everytime you do a commit.
+Alternatively, you can ignore the file by marking it
+as assumed unchanged
+$ git update-index --assume-unchanged foo
+Now the file behaves as if it were ignored
+$ git status
+...
+`foo` won't appear listed in status
+
+Start tracking changes to `foo` again
+$ git update-index --no-assume-unchanged foo
+$ git status
+...
+Changes not staged for commit:
+...
+  modified: foo
+...
+
+
+
+
+
+ + + +

Branching

+

+ The main thing to understand is that in Gitless a branch is a completely + independent line of development. Each branch keeps its working version + of files separate from each other. Whenever you switch to a different + branch, the contents of your working directory are saved, and the ones + corresponding to the branch you are switching to are retrieved. The + classifications of files are also saved (i.e., a file can + be untracked on some branch but tracked on another and Gitless will + remember this): +

+
+
$ gl status
+...
+Tracked files with modifications:
+...
+  foo
+...
+
+Create new branch `develop`
+$ gl branch -c develop
+✔ Created new branch develop
+
+Switch to `develop`
+$ gl switch develop
+✔ Switched to branch develop
+$ gl status
+... no changes to foo here
+
+
+
+
+Switch back to `master`
+$ gl switch master
+✔ Switched to branch master
+$ gl status
+...
+Tracked files with modifications:
+...
+  foo
+...
+If you want the uncommitted changes to follow you
+into the new branch you can use the mo/move-over
+flag to move over the changes in the current branch
+to the destination branch
+
+
$ git status
+...
+Changes not staged for commit:
+...
+  modified: foo
+...
+
+Create new branch `develop`
+$ git branch develop
+
+
+Switch to `develop`
+$ git checkout develop
+$ git status
+...
+Changes not staged for commit:
+...
+  modified: foo
+...
+
+Switch back to `master`
+$ git checkout master
+...
+Changes not staged for commit:
+...
+  modified: foo
+...
+
+
+
+
+
+
+
+
+
+

+ This means that in Gitless you don't have to worry about uncommitted + changes conflicting with the changes in the destination branch: +

+
+
Say we have uncommitted changes to `foo` that
+conflict with the state of `foo` in branch `develop`
+
+Switch to `develop`
+$ gl switch develop
+✔ Switched to branch develop
+
+
+
+Say we have uncommitted changes to `foo` that
+conflict with the state of `foo` in branch `develop`
+
+Switch to `develop`
+$ git checkout develop
+error: Your local changes to the following files
+would be overwritten by checkout:
+     foo
+Please, commit your changes or stash them before
+you can switch branches.
+Aborting
+$ git stash
+Saved working directory and index state WIP on
+master: fbe3b8c ...
+HEAD is now at fbe3b8c ...
+$ git checkout develop
+Switched to branch 'develop'
+
+
+
+

+ And if you are in the middle of a fuse/merge and you want to put aside + the conflict resolution for later, you can. The conflict will be there + when you switch back: +

+
+
$ gl fuse develop
+...
+✘  There are conflicts you need to resolve
+$ gl status
+On branch master, repo-directory //
+
+You are in the middle of a fuse; all
+conflicts must be resolved before
+committing
+...
+Tracked files with modifications:
+...
+  foo (with conflicts)
+...
+
+
+
+
+
+
+
+
+
+Switch to `bugfix`
+$ gl switch bugfix
+✔ Switched to branch bugfix
+No conflicts here
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Switch back to `develop`
+$ gl switch develop
+✔ Switched to branch develop
+$ gl status
+On branch master, repo-directory //
+
+You are in the middle of a fuse; all
+conflicts must be resolved before
+committing
+...
+Tracked files with modifications:
+...
+  foo (with conflicts)
+...
+
+
$ git rebase develop
+First, rewinding head to replay your work on top of
+it...
+Applying: this commit should trigger a conflict
+Using index info to reconstruct a base tree...
+M foo
+Falling back to patching base and 3-way merge...
+Auto-merging foo
+CONFLICT (content): Merge conflict in foo
+Failed to merge in the changes.
+Patch failed at 0001 foo conflict
+The copy of the patch that failed is found in:
+   ...
+$ git status
+rebase in progress; onto 989269e
+You are currently rebasing branch 'master' on
+'989269e'.
+  ...
+Unmerged paths:
+  ...
+  both modified:   foo
+...
+
+Switch to `bugfix`
+$ git checkout bugfix
+foo: needs merge
+error: you need to resolve your current index first
+Maybe stash works?
+$ git stash
+foo: needs merge
+foo: needs merge
+foo: unmerged (0b3c542edb2e9e8ff801c669d7a5f2d78...)
+foo: unmerged (94421333de34e32405f632d0f7b63b39c...)
+foo: unmerged (eb97dba229aab53fe5f231e60491dd2a7...)
+fatal: git-write-tree: error building trees
+Cannot save the current index state
+ ...hmm..ok...
+$ save all the changes to files somewhere else
+$ git rebase --abort
+$ git checkout bugfix
+Switched to branch 'bugfix'
+
+Switch back to `develop`
+$ git checkout develop
+$ git status
+...
+We aborted the rebase to be able to switch branches,
+so there's nothing here now
+$ git rebase develop
+... same conflict as before
+$ Copy back the changes we saved
+
+
+
+
+
+
+
+
+ + + +

Working with Remote Repositories

+

+ Syncing with other repositories in Gitless works quite similar to + Git: +

+
+
Configure a new remote `try-gitless`
+$ gl remote -c try-gitless
+https://github.com/gitless-vcs/try-gitless
+✔ Remote try-gitless mapping to
+https://github.com/gitless-vcs/try-gitless
+created successfully
+  ➜ to list existing remotes do gl remote
+  ➜ to remove try-gitless do gl remote -d try-gitless
+
+Download and apply changes from a branch in
+`try-gitless`
+$ gl fuse try-gitless/master
+...
+✔ Fuse succeeded
+If you instead want to merge the changes, you
+can do `gl merge try-gitless/master`
+
+You can also set an upstream for the current branch
+$ gl branch -su try-gitless/master
+✔ Current branch master set to track
+try-gitless/master
+$ gl fuse
+! No src branch specified, getting changes from
+upstream branch try-gitless/master
+...
+✔ Fuse succeeded
+
+Send changes to the remote
+$ gl publish
+! No src branch specified, sending changes to
+upstream branch try-gitless/master
+✔ Publish succeeded
+Only the changes in the current branch are uploaded
+
+Configure a new remote `try-gitless`
+$ git remote add try-gitless
+https://github.com/gitless-vcs/try-gitless
+
+
+
+
+
+
+Download and apply changes from a branch in
+`try-gitless`
+$ git pull try-gitless/master
+...
+If you instead want to rebase the changes, you can
+do `git pull --rebase try-gitless/master`
+
+You can also set an upstream for the current branch
+$ git branch --set-upstream master try-gitless/master
+$ git pull
+...
+
+
+
+
+
+
+
+Send changes to the remote
+$ git push
+What happens when you do a push depends on the value
+of the `push.default` config variable
+
+
+
+
+
+ + +
+ + + +
+

Research

+

+ If you are + interested in software design and want to learn more about the research + behind Gitless, take a look at the following papers: +

+ +

+ If you want a thirty-minutes summary presentation, watch "What’s Wrong With Git?" from Git Merge 2017. +

+ +
+ + + + + +
+ + diff --git a/unfurl/src/commonTest/resources/html_source_instagram.com.html b/unfurl/src/commonTest/resources/html_source_instagram.com.html new file mode 100644 index 0000000..837df1a --- /dev/null +++ b/unfurl/src/commonTest/resources/html_source_instagram.com.html @@ -0,0 +1,56 @@ + + + +Instagram | About | Official Site + + +

Give people the power to build community and bring the world closer

@janicesung illustration person with black hair sunflowers
Winking dog with tongue out and sweater
Portrait in red smoke
Star necklace over striped sweater puffer coat
Portrait with dinosaur figurine
Smiling with curly hair and hoops
Portrait red sweater hair over face
Selfie with orange sari and gold earrings
Stopped on bike showing sneakers
Illustration figure reading in giant flowers

Tap and hold

ALL ARE WELCOME

We're committed to fostering a safe and supportive community for everyone.

Community
Collection of posts in search & explore

EXPLORE WHAT'S NEW

Express yourself in new ways with the latest Instagram features.

Features

DISCOVER REELS

Create, share, and watch short, entertaining videos on Instagram.

Reels

WATCH STORIES

Check out Stories and live videos from your favorite people.

Stories

HAVE A CONVERSATION

Send messages, photos and videos to a friend or select group of people.

Messenger

SEE IT HAPPEN

Watch and discover engaging videos.

Video

SHOP WHAT YOU LOVE

Browse the latest trends from your favorite brands and creators.

Shopping

FIND SOMETHING NEW

Discover content and creators based on your interests.

Search & Explore

STAND OUT ON INSTAGRAM

Connect with more people, build influence, and create compelling content that's distinctly yours.

Creators
Shadow of blinds on red wall
Lone cloud
Orange and yellow folds and shadows
Gold pearl hoop earrings

GROW WITH US

Share and grow your brand with our diverse, global community.

Business

Download for iOS/Android.

+ + + diff --git a/unfurl/src/commonTest/resources/html_source_saket.me.html b/unfurl/src/commonTest/resources/html_source_saket.me.html new file mode 100644 index 0000000..2f9a14d --- /dev/null +++ b/unfurl/src/commonTest/resources/html_source_saket.me.html @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + Great teams merge fast + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+
+ + + + + +
+ +

Great teams merge fast

+
+ + + + + in Uncategorized + + + (edit) + +
+ + +
+ +
+ +

One of the many things I was constantly amazed about during my first few weeks at Square/Cash App was seeing how fast this team shipped code. All members of the Android team (and likely other platforms as well) were merging to the main git branch multiple times every day.

+

This was in stark contrast to my previous company where it was common for us to raise large PRs after a couple days of work. Getting them merged-in wasn’t fast either. Reviewing these PRs was one of my least favorite parts of my work. In all fairness, I was always the first person to object that we can’t merge faster. Boy was I wrong.

+

To other teams who feel they can be merging faster, here are a few things I’ve noticed I’m doing differently at Square:

+

Trust your team members

+

When reviewing PRs, we approve them immediately after leaving our feedback instead of holding it to ransom until our requested changes are made. This unblocks people from merging their PRs once they’ve resolved all feedback instead of waiting for another round of reviews. We trust our team members that they’re also trying to do their best for the project.

+

If someone merges code that shouldn’t have been merged, that’s okay. A broken main branch can be quickly fixed. Bad commits can be easily reverted because rolling release windows are very forgiving. Nobody expects internal staging builds to be fully stable.

+

Automate nitpicking and bikeshedding

+

+

Code suggestions that often come up in PR reviews are bad signs (Dan Lew agrees). One of my favorite parts of our stack of static checks is a DenyListedApiDetector linter that discourages developers from using APIs we dislike. It’s like Kotlin’s @Deprecated annotation, but for code we don’t own — like the Android SDK or third-party libraries. For example, we block usages of Observable#test() in favor of an internal implementation that’s like cashapp/turbine but for RxJava.

+

Here’s another example/question for you: how many of these APIs does your Android app use for reading XML drawables?

+
1. Context#getDrawable()
+2. ContextCompat.getDrawable()
+3. AppCompatResources.getDrawable()
+4. ResourcesCompat.getDrawable()
+

Only one of them is universally correct. We’ve deny-listed the rest of them.

+

Stack PRs

+

PRs that introduce 200 lines of changes or lower are the best. Anything more than that and the likelihood of reviewers skimming through crucial changes increases with every line, especially if they own Logitech MX Master that can scroll billions of lines with a single flick. For related work, we stack our PRs on top of each other so that they can be reviewed super quickly. GitHub automatically handles re-targeting of stacked PRs when their base is merged.

+

I like to think that by making it easy to review PRs, we’re essentially showing empathy towards our customers. An unsuspecting bad piece of code can potentially result in people being unable to use their money and that’ll be a terrible thing to do.

+

Ship work-in-progress code to production

+
if (featureFlags[SuperSecretFeature].isEnabled) {
+  navigator.goTo(SuperSecretScreen())
+} else {
+  navigator.goTo(BoringOldScreen())
+}
+
+

Merging code daily effectively translates to shipping work-in-progress code in production builds. To keep customers from running new features or incomplete refactorings of old code, we use feature flags.

+

While this may sound obvious to some, I’ve worked on teams that instead held off merging their work until it’s ready. We were able to make it work, but it had huge downsides. Feature branches isolate your team, preventing other developers from seeing what you’re working on until it’s done. In large teams, this often leads to people organizing merge-conflict resolving parties. They’re not fun.

+

Feature flags are also great for undoing mistakes. At Cash App’s scale, refactoring code that’s used by millions of customers is sometimes scary, but I feel comfortable knowing that I can push a button and remotely switch all our customers from BottomSheetV2 to BottomSheetV1 if an obscure bug is found in v2.

+

JUnit snapshot tests

+

+
com.squareup.cash.mooncake.AlertDialogViewTest > message only FAILED
+java.lang.AssertionError: Images differ (by 0.7%)
+
+

While unit tests are great for increasing confidence in making changes to existing business logic, the same hasn’t been true for UI. Companies often rely on a combination of Android tests and manual QA, both of which aren’t ideal. Android tests are super slow and manual testing is… well humans will make mistakes. We’re solving this with paparazzi that lets us write snapshot tests as JUnit tests, without the requirement of an actual device.

+

Automated snapshot tests are great for ensuring that our layouts look pixel perfect with various configuration factors like display density, dark mode, up-scaled font size for accessibility, etc. Paparazzi is being actively developed, but you can try out an early preview: github.com/cashapp/paparazzi.

+
+

Wrapping up, I like to think that when an engineering team is empowered to merge fast, it both demonstrates and cultivates trust in each other. Barriers to merging code are best used for high-level design discussions and pain points in the codebase, and not as a substitute for pairing sessions.

+
Thanks to Egor Andreevici and Bill Phillips for reviewing this article. Cover photo by Sawyer Bengtson.
+ +
+ + +
+ +

#

+ +
+ + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unfurl/src/iosMain/kotlin/me/saket/unfurl/Platform.ios.kt b/unfurl/src/iosMain/kotlin/me/saket/unfurl/Platform.ios.kt new file mode 100644 index 0000000..62d153c --- /dev/null +++ b/unfurl/src/iosMain/kotlin/me/saket/unfurl/Platform.ios.kt @@ -0,0 +1,9 @@ +package me.saket.unfurl + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.darwin.Darwin + +internal actual fun provideHttpClientEngine(): HttpClientEngine { + return Darwin.create { + } +} diff --git a/unfurl/src/jvmMain/kotlin/Platform.jvm.kt b/unfurl/src/jvmMain/kotlin/Platform.jvm.kt new file mode 100644 index 0000000..c5cc3d4 --- /dev/null +++ b/unfurl/src/jvmMain/kotlin/Platform.jvm.kt @@ -0,0 +1,9 @@ +package me.saket.unfurl + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.okhttp.OkHttp + +internal actual fun provideHttpClientEngine(): HttpClientEngine { + return OkHttp.create { + } +} From aadb593c46448fe51d0fd09aa9b4a31d213783e7 Mon Sep 17 00:00:00 2001 From: laatonwalabhoot Date: Mon, 27 Nov 2023 05:48:14 +0530 Subject: [PATCH 2/5] chore: remove extra module --- .../kotlin/me/saket/unfurl/UnfurlLogger.kt | 24 ---- .../kotlin/me/saket/unfurl/UnfurlResult.kt | 24 ---- .../main/kotlin/me/saket/unfurl/Unfurler.kt | 52 --------- .../me/saket/unfurl/delegates/deprecations.kt | 15 --- .../unfurl/delegates/html/deprecations.kt | 7 -- .../kotlin/me/saket/unfurl/deprecations.kt | 49 -------- .../unfurl/extension/HtmlMetadataParser.kt | 107 ------------------ .../unfurl/extension/HtmlTagsBasedUnfurler.kt | 61 ---------- .../unfurl/extension/UnfurlerExtension.kt | 15 --- .../me/saket/unfurl/internal/LruCache.kt | 14 --- .../saket/unfurl/internal/NullableLruCache.kt | 23 ---- 11 files changed, 391 deletions(-) delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/UnfurlLogger.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/UnfurlResult.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/Unfurler.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/delegates/deprecations.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/delegates/html/deprecations.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/deprecations.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/internal/LruCache.kt delete mode 100644 unfurl/src/main/kotlin/me/saket/unfurl/internal/NullableLruCache.kt diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlLogger.kt b/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlLogger.kt deleted file mode 100644 index c501780..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlLogger.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.saket.unfurl - -interface UnfurlLogger { - fun log(message: String) - fun log(e: Throwable, message: String) - - companion object - - object Println : UnfurlLogger { - override fun log(e: Throwable, message: String) { - println(message) - println(e.stackTraceToString()) - } - - override fun log(message: String) { - println(message) - } - } - - object NoOp : UnfurlLogger { - override fun log(message: String) = Unit - override fun log(e: Throwable, message: String) = Unit - } -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlResult.kt b/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlResult.kt deleted file mode 100644 index 86f5cda..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/UnfurlResult.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.saket.unfurl - -import okhttp3.HttpUrl - -/** - * @param url May or may not be equal to the original URL used with [Unfurler.unfurl]. - * This can happen in situations where HTTP 3xx redirects are followed. For example, - * `https://youtu.be/foo` will redirect to `https://www.youtube.com/watch?v=foo`. - */ -data class UnfurlResult( - val url: HttpUrl, - val title: String?, - val description: String?, - val favicon: HttpUrl?, - val thumbnail: HttpUrl?, - val contentPreview: ContentPreview? = null, -) { - - /** - * Additional metadata that can be populated by extensions. - * See `TweetContentPreview` for an example. - */ - interface ContentPreview -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/Unfurler.kt b/unfurl/src/main/kotlin/me/saket/unfurl/Unfurler.kt deleted file mode 100644 index 74f1c66..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/Unfurler.kt +++ /dev/null @@ -1,52 +0,0 @@ -package me.saket.unfurl - -import me.saket.unfurl.extension.HtmlTagsBasedUnfurler -import me.saket.unfurl.extension.UnfurlerExtension -import me.saket.unfurl.extension.UnfurlerScope -import me.saket.unfurl.internal.NullableLruCache -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient - -class Unfurler( - cacheSize: Int = 100, - extensions: List = emptyList(), - val httpClient: OkHttpClient = defaultOkHttpClient(), - val logger: UnfurlLogger = UnfurlLogger.Println, -) { - private val extensions = extensions + HtmlTagsBasedUnfurler() - private val cache = NullableLruCache(cacheSize) - - private val extensionScope = object : UnfurlerScope { - override val httpClient: OkHttpClient get() = this@Unfurler.httpClient - override val logger: UnfurlLogger get() = this@Unfurler.logger - } - - fun unfurl(url: String): UnfurlResult? { - return cache.computeIfAbsent(url) { - try { - url.toHttpUrlOrNull()?.let { httpUrl -> - extensions.asSequence() - .mapNotNull { it.run { extensionScope.unfurl(httpUrl) } } - .firstOrNull() - } - } catch (e: Throwable) { - logger.log(e, "Failed to unfurl '$url'") - null - } - } - } - - fun unfurl(url: HttpUrl): UnfurlResult? { - return unfurl(url.toString()) - } - - companion object { - fun defaultOkHttpClient(): OkHttpClient { - return OkHttpClient.Builder() - .followRedirects(true) - .followSslRedirects(true) - .build() - } - } -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/delegates/deprecations.kt b/unfurl/src/main/kotlin/me/saket/unfurl/delegates/deprecations.kt deleted file mode 100644 index a1f3a5d..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/delegates/deprecations.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.saket.unfurl.delegates - -import me.saket.unfurl.extension.UnfurlerExtension - -@Deprecated( - message = "Renamed to UnfurlerExtension", - replaceWith = ReplaceWith("me.saket.unfurl.extension.UnfurlerExtension") -) -interface UnfurlerDelegate : UnfurlerExtension - -@Deprecated( - message = "Renamed to UnfurlerExtensionScope", - replaceWith = ReplaceWith("me.saket.unfurl.extension.UnfurlerScope") -) -typealias UnfurlerDelegateScope = me.saket.unfurl.extension.UnfurlerScope diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/delegates/html/deprecations.kt b/unfurl/src/main/kotlin/me/saket/unfurl/delegates/html/deprecations.kt deleted file mode 100644 index 1825563..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/delegates/html/deprecations.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.saket.unfurl.delegates.html - -@Deprecated( - message = "Renamed to HtmlTagsBasedUnfurler", - replaceWith = ReplaceWith("me.saket.unfurl.extension.HtmlTagsBasedUnfurler") -) -typealias HtmlTagsBasedUnfurler = me.saket.unfurl.extension.HtmlTagsBasedUnfurler diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/deprecations.kt b/unfurl/src/main/kotlin/me/saket/unfurl/deprecations.kt deleted file mode 100644 index be38c74..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/deprecations.kt +++ /dev/null @@ -1,49 +0,0 @@ -package me.saket.unfurl - -import me.saket.unfurl.delegates.UnfurlerDelegate -import okhttp3.OkHttpClient - -@Deprecated( - message = """"delegates" parameter has been renamed to "extensions"""", - replaceWith = ReplaceWith( - "Unfurler(cacheSize = cacheSize, extensions = delegates, httpClient = httpClient)", - "me.saket.unfurl.Unfurler" - ), -) -fun Unfurler( - cacheSize: Int = 100, - delegates: List, - httpClient: OkHttpClient = Unfurler.defaultOkHttpClient(), -) = Unfurler( - cacheSize = cacheSize, - extensions = delegates, - httpClient = httpClient, -) - -@Deprecated( - message = """"delegates" parameter has been renamed to "extensions"""", - replaceWith = ReplaceWith( - "Unfurler(cacheSize = cacheSize, extensions = delegates)", - "me.saket.unfurl.Unfurler" - ), -) -fun Unfurler( - cacheSize: Int = 100, - delegates: List, -) = Unfurler( - cacheSize = cacheSize, - extensions = delegates, -) - -@Deprecated( - message = """"delegates" parameter has been renamed to "extensions"""", - replaceWith = ReplaceWith( - "Unfurler(extensions = delegates)", - "me.saket.unfurl.Unfurler" - ), -) -fun Unfurler( - delegates: List -) = Unfurler( - extensions = delegates, -) diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt b/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt deleted file mode 100644 index eadfb28..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlMetadataParser.kt +++ /dev/null @@ -1,107 +0,0 @@ -package me.saket.unfurl.extension - -import me.saket.unfurl.UnfurlLogger -import me.saket.unfurl.UnfurlResult -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -internal class HtmlMetadataParser(private val logger: UnfurlLogger) { - - fun parse(url: HttpUrl, document: Document): UnfurlResult { - return UnfurlResult( - url = url, - title = parseTitle(document)?.trim(), - description = parseDescription(document)?.trim(), - favicon = parseFaviconUrl(document) ?: fallbackFaviconUrl(url), - thumbnail = parseThumbnailUrl(document) - ) - } - - private fun parseTitle(document: Document): String? { - val linkTitle = metaTag(document, "twitter:title") - ?: metaTag(document, "og:title") - ?: document.title().nullIfBlank() - - if (linkTitle == null) { - logger.log("couldn't find any title for ${document.baseUri()}.") - } - return linkTitle - } - - private fun parseDescription(document: Document): String? { - val linkTitle = metaTag(document, "twitter:description") - ?: metaTag(document, "og:description") - ?: metaTag(document, "description") - - if (linkTitle == null) { - logger.log("couldn't find any description for ${document.baseUri()}.") - } - return linkTitle - } - - private fun parseThumbnailUrl(document: Document): HttpUrl? { - // Twitter's image tag is preferred over facebook's - // because websites seem to give better images for twitter. - val thumbnailUrl = metaTag(document, "twitter:image", isUrl = true) - ?: metaTag(document, "og:image", isUrl = true) - ?: metaTag(document, "twitter:image:src", isUrl = true) - ?: metaTag(document, "og:image:secure_url", isUrl = true) - - // So... scheme-less URLs are a thing. - val needsScheme = thumbnailUrl != null && thumbnailUrl.startsWith("//") - return (if (needsScheme) "https:$thumbnailUrl" else thumbnailUrl)?.toHttpUrlOrNull() - } - - private fun parseFaviconUrl(document: Document): HttpUrl? { - val faviconUrl = linkRelTag(document, "apple-touch-icon") - ?: linkRelTag(document, "apple-touch-icon-precomposed") - ?: linkRelTag(document, "shortcut icon") - ?: linkRelTag(document, "icon") - return faviconUrl?.toHttpUrlOrNull() - } - - private fun fallbackFaviconUrl(url: HttpUrl): HttpUrl { - return HttpUrl.Builder() - .scheme(url.scheme) - .host(url.host) - .encodedPath("/favicon.ico") - .build() - } - - private fun metaTag(document: Document, attr: String, isUrl: Boolean = false): String? { - val names = document.select("meta[name=$attr]") - val properties = document.select("meta[property=$attr]") - - return sequenceOf(names, properties) - .flatMap { it } - .mapNotNull { element: Element -> - element.attr(if (isUrl) "abs:content" else "content").nullIfBlank() - } - .firstOrNull() - } - - private fun linkRelTag(document: Document, rel: String): String? { - val elements = document.head().select("link[rel=$rel]") - var largestSizeUrl = elements.firstOrNull()?.attr("abs:href") ?: return null - var largestSize = 0 - - for (element in elements) { - // Some websites have multiple icons for different sizes. Find the largest one. - val sizes = element.attr("sizes") - if (sizes.contains("x")) { - val size = sizes.split("x")[0].toInt() - if (size > largestSize) { - largestSize = size - largestSizeUrl = element.attr("abs:href") - } - } - } - return largestSizeUrl - } -} - -private fun String.nullIfBlank(): String? { - return ifBlank { null } -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt b/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt deleted file mode 100644 index f98e94e..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/extension/HtmlTagsBasedUnfurler.kt +++ /dev/null @@ -1,61 +0,0 @@ -package me.saket.unfurl.extension - -import me.saket.unfurl.UnfurlResult -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType -import okhttp3.Request -import org.jsoup.Jsoup -import org.jsoup.nodes.Document - -open class HtmlTagsBasedUnfurler : UnfurlerExtension { - override fun UnfurlerScope.unfurl(url: HttpUrl): UnfurlResult? { - return downloadHtml(url)?.let { doc -> - extractMetadata(doc) - } - } - - private fun UnfurlerScope.downloadHtml(url: HttpUrl): Document? { - val request: Request = Request.Builder() - // Some websites will deny empty/unknown user agents, probably in an - // attempt to prevent scrapers? Some goes for the following headers. - .header( - "User-Agent", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36" - ) - .header("Accept", "text/html") - .header("Accept-Language", "en-US,en;q=0.5") - .url(url) - .build() - - return try { - httpClient.newCall(request).execute().use { response -> - val body = response.body - val redirectedUrl = response.request.url - - if (body != null && body.contentType().isHtmlText()) { - // TODO: stream the HTML body only until a "" is received instead of streaming the entire HTML body. - Jsoup.parse( - /* in */ body.source().inputStream(), - /* charsetName */ null, - /* baseUri */ redirectedUrl.toString() - ) - } else { - null - } - } - } catch (e: Throwable) { - logger.log(e, "Failed to download HTML for $url") - null - } - } - - open fun UnfurlerScope.extractMetadata(document: Document): UnfurlResult? { - val parser = HtmlMetadataParser(logger) - return parser.parse(url = document.baseUri().toHttpUrl(), document = document) - } - - private fun MediaType?.isHtmlText(): Boolean { - return this != null && type == "text" && subtype == "html" - } -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt b/unfurl/src/main/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt deleted file mode 100644 index 6670ffc..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/extension/UnfurlerExtension.kt +++ /dev/null @@ -1,15 +0,0 @@ -package me.saket.unfurl.extension - -import me.saket.unfurl.UnfurlLogger -import me.saket.unfurl.UnfurlResult -import okhttp3.HttpUrl -import okhttp3.OkHttpClient - -interface UnfurlerExtension { - fun UnfurlerScope.unfurl(url: HttpUrl): UnfurlResult? -} - -interface UnfurlerScope { - val httpClient: OkHttpClient - val logger: UnfurlLogger -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/internal/LruCache.kt b/unfurl/src/main/kotlin/me/saket/unfurl/internal/LruCache.kt deleted file mode 100644 index de6285f..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/internal/LruCache.kt +++ /dev/null @@ -1,14 +0,0 @@ -package me.saket.unfurl.internal - -class LruCache( - private val cacheSize: Int -) : LinkedHashMap(cacheSize, /* loadFactor = */0.75f, /* accessOrder = */ true) { - - init { - require(cacheSize >= 0) - } - - override fun removeEldestEntry(eldest: Map.Entry): Boolean { - return size > cacheSize - } -} diff --git a/unfurl/src/main/kotlin/me/saket/unfurl/internal/NullableLruCache.kt b/unfurl/src/main/kotlin/me/saket/unfurl/internal/NullableLruCache.kt deleted file mode 100644 index 2e5f774..0000000 --- a/unfurl/src/main/kotlin/me/saket/unfurl/internal/NullableLruCache.kt +++ /dev/null @@ -1,23 +0,0 @@ -package me.saket.unfurl.internal - -import me.saket.unfurl.internal.NullableLruCache.Optional.None -import me.saket.unfurl.internal.NullableLruCache.Optional.Some - -internal class NullableLruCache(maxSize: Int) { - private val delegate = LruCache>(maxSize) - - inline fun computeIfAbsent(key: K, create: () -> V?): V? { - return when (val cached = delegate[key]) { - is Some -> cached.value - is None -> null - null -> create().also { - delegate[key] = if (it == null) None else Some(it) - } - } - } - - sealed class Optional { - data class Some(val value: T) : Optional() - object None : Optional() - } -} From 0f46a23af752b1cc81ddb1262317358c0a779557 Mon Sep 17 00:00:00 2001 From: laatonwalabhoot Date: Mon, 27 Nov 2023 05:48:27 +0530 Subject: [PATCH 3/5] nit: remove redundant comment --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 588e460..c8f5dca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,4 @@ plugins { - //trick: for the same plugin versions in all sub-modules alias(libs.plugins.androidLibrary).apply(false) alias(libs.plugins.kotlinMultiplatform).apply(false) alias(libs.plugins.dokka) From 6d6456f4f2bc08324cfc382d5af54bc28865b589 Mon Sep 17 00:00:00 2001 From: laatonwalabhoot Date: Mon, 27 Nov 2023 05:51:20 +0530 Subject: [PATCH 4/5] nit: remove extra lines --- gradle/libs.versions.toml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 667b94f..abf0519 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,13 +5,10 @@ dokka = "1.9.10" ksoup = "0.0.6" vanniktect-publish = "0.25.3" ktor = "2.3.6" - - compileSdk = "34" minSdk = "24" libraryVersion = "1.8.0" - [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } @@ -28,4 +25,4 @@ kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } -vanniktectPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "vanniktect-publish" } \ No newline at end of file +vanniktectPublish = { id = "com.vanniktech.maven.publish.base", version.ref = "vanniktect-publish" } From 00a565514e91014267bb90632c02e071bdaa850c Mon Sep 17 00:00:00 2001 From: laatonwalabhoot Date: Mon, 27 Nov 2023 05:51:36 +0530 Subject: [PATCH 5/5] fix: root project name --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index a1fa465..c6e89ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,5 +14,5 @@ dependencyResolutionManagement { } } -rootProject.name = "unfurl-kmp" +rootProject.name = "unfurl" include(":unfurl") \ No newline at end of file