From 3e955b3f73441bd5f248ef3d8c61ebb0e12f8cb3 Mon Sep 17 00:00:00 2001 From: Antonin Delpeuch Date: Fri, 4 Dec 2020 08:50:10 +0100 Subject: [PATCH 01/20] Migrate from Travis to GitHub Actions (#3378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Only keep JRE 8 and 14 in the build matrix. For #3377. * Run tests in GitHub Actions on each PR * Attempt to fix Postgres configuration * Set explicit password for Postgres on GitHub Actions * Set explicit password for MySQL and MariaDB * Fix credentials for postgres and mysql * Fix duplicate id in GitHub workflow * Fix creation of test_db on MySQL * Revert back to GH Action MySQL service * Populate initial test databases * Fix syntax of workflow file * Reorder steps to give more time for MySQL to boot * Run MySQL database as a service, forward ports to config * Reformat MySQL options * Fix YAML syntax * Add missing 'steps' field * Fix connection to MySQL and Postgres * Add back explicit database creation steps * Force TCP/IP connection for postgres * Remove explicit creation of test database for postgres * Fix Postgres and Mariadb configs * Fix parameter spelling for mariadb * Display MariaDB test configuration * Fix more inconsistent parameter names * Fix more inconsistent parameter names * Attempt to use Coveralls maven plugin instead of GH action * Fix workflow file * Enable submission to coveralls * Supply coveralls token * Remove Travis-specific configuration files * Also update appveyor script after rename of SQL files * Reintroduce packaging/test_pom.xml used by Appveyor * Update filenames in appveyor.yml --- .github/workflows/pull_request.yml | 71 ++++++++++++++++ .../{maven.yml => snapshot_release.yml} | 6 +- .travis.yml | 85 ------------------- appveyor.yml | 6 +- .../database/tests/conf/appveyor_tests.xml | 10 +-- .../tests/conf/github_actions_tests.xml | 55 ++++++++++++ .../{travis-mariadb.sql => test-mariadb.sql} | 0 .../conf/{travis-mysql.sql => test-mysql.sql} | 0 .../conf/{travis-pgsql.sql => test-pgsql.sql} | 0 .../{travis-sqlite.sql => test-sqlite.sql} | 0 extensions/database/tests/conf/tests.xml | 10 +-- .../database/tests/conf/travis_tests.xml | 57 ------------- .../database/DatabaseTestConfig.java | 6 +- .../mariadb/MariaDBConnectionManagerTest.java | 2 +- .../mariadb/MariaDBDatabaseServiceTest.java | 2 +- packaging/{travis_pom.xml => test_pom.xml} | 0 16 files changed, 147 insertions(+), 163 deletions(-) create mode 100644 .github/workflows/pull_request.yml rename .github/workflows/{maven.yml => snapshot_release.yml} (97%) delete mode 100644 .travis.yml create mode 100644 extensions/database/tests/conf/github_actions_tests.xml rename extensions/database/tests/conf/{travis-mariadb.sql => test-mariadb.sql} (100%) rename extensions/database/tests/conf/{travis-mysql.sql => test-mysql.sql} (100%) rename extensions/database/tests/conf/{travis-pgsql.sql => test-pgsql.sql} (100%) rename extensions/database/tests/conf/{travis-sqlite.sql => test-sqlite.sql} (100%) delete mode 100644 extensions/database/tests/conf/travis_tests.xml rename packaging/{travis_pom.xml => test_pom.xml} (100%) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 000000000..a9509f2a5 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,71 @@ +name: Java CI + +on: [pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + ports: + - 5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 'postgres' + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mysql: + image: mysql:8 + ports: + - 3306 + env: + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd "mysqladmin ping" + --health-interval 5s + --health-timeout 2s + --health-retries 3 + + steps: + - uses: actions/checkout@v2.3.4 + + - name: Restore dependency cache + uses: actions/cache@v2.1.3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: Configure connections to databases + id: configure_db_connections + run: cat extensions/database/tests/conf/github_actions_tests.xml | sed -e "s/MYSQL_PORT/${{ job.services.mysql.ports[3306] }}/g" | sed -e "s/POSTGRES_PORT/${{ job.services.postgres.ports[5432] }}/g" > extensions/database/tests/conf/tests.xml + + - name: Populate databases with test data + id: populate_databases_with_test_data + run: | + mysql -u root -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} -proot -e 'CREATE DATABASE test_db;' + mysql -u root -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} -proot < extensions/database/tests/conf/test-mysql.sql + psql -U postgres test_db -h 127.0.0.1 -p ${{ job.services.postgres.ports[5432] }} < extensions/database/tests/conf/test-pgsql.sql + env: + PGPASSWORD: postgres + + - name: Build and test with Maven + run: mvn jacoco:prepare-agent test + + - name: Submit test coverage to Coveralls + run: | + mvn prepare-package -DskipTests=true + mvn jacoco:report coveralls:report -DrepoToken=${{ secrets.COVERALLS_TOKEN }} -DpullRequest=${{ github.event.number }} diff --git a/.github/workflows/maven.yml b/.github/workflows/snapshot_release.yml similarity index 97% rename from .github/workflows/maven.yml rename to .github/workflows/snapshot_release.yml index ffdb8f251..76eb2972e 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/snapshot_release.yml @@ -1,4 +1,4 @@ -name: Java CI +name: Snapshot release on: push: @@ -31,8 +31,8 @@ jobs: - name: Install genisoimage and jq run: sudo apt-get install genisoimage jq - - name: Build with Maven - run: ./refine build + - name: Build and test with Maven + run: ./refine test - name: Get the OpenRefine snapshot version run: echo ::set-env name=OR_VERSION::$(cat ./main/webapp/WEB-INF/classes/git.properties | jq -r '.["git.commit.id.describe"]') diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f5b2f39eb..000000000 --- a/.travis.yml +++ /dev/null @@ -1,85 +0,0 @@ -os: linux -language: java -dist: bionic - -jobs: - include: - - dist: trusty # Fastest build first & for all builds - jdk: oraclejdk8 # Trusty default - # Full matrix only for merges to master or anything to do with release branches e.g. v3.5 - - if: (branch = master AND type != pull_request) OR branch ~= /^v\d\.\d.*/ - jdk: openjdk11 # Bionic default - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - jdk: openjdk12 - dist: xenial # just for a little variety - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - jdk: openjdk13 - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - jdk: openjdk14 # replacement for OS X Java 14 build - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - os: osx - osx_image: xcode11.6 # macOS 10.15.4, Oracle JDK 14.0.1 - language: java - services: # not supported on os x - addons: - homebrew: - packages: - - mysql - - mariadb@10.3 - before_script: - - brew services start mysql - - brew services start postgresql - - brew services start mariadb@10.3 - - sleep 15 # wait for databases to start up - # Homebrew postgres workaround - create expected user postgres - - /usr/local/opt/postgres/bin/createuser -s postgres - # FIXME this is duplicated from linux config, but don't know a better way to do it - - mysql -u root -e 'CREATE DATABASE test_db;' - - mysql -u root test_db < extensions/database/tests/conf/travis-mysql.sql - - psql -c 'CREATE DATABASE test_db;' -U postgres - - psql -U postgres test_db < extensions/database/tests/conf/travis-pgsql.sql - - cp extensions/database/tests/conf/travis_tests.xml extensions/database/tests/conf/tests.xml - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - jdk: oraclejdk-ea - - if: branch = master AND type != pull_request OR branch ~= /^v\d\.\d.*/ - jdk: openjdk-ea - allow_failures: - - os: osx - - jdk: openjdk-ea - - jdk: oraclejdk-ea - -addons: - mariadb: '10.3' - -services: - - mysql - - postgresql - -env: - # encrypted Codacy key, see https://docs.travis-ci.com/user/encryption-keys/ - - secure: "VmS4He99YlI6rdmw8Q25OZ9kUp11sRbt0W1QMBvA5lzNSmhN1Q1KtaMj9AGwpCZWcyGWri4AQxEmloARxACxQHXRmNE7ro2DESGw46RAocBAf+RfBxYTifIyUGu5TnSCQhz56SkgpyWpedZAZWyah9ZxgUMfet4KXFUfeiUgYQA=" - -before_install: - # Fake out packaging for Travis builds before mvn install - - cp packaging/travis_pom.xml packaging/pom.xml - - mvn process-resources - -before_script: - # create test databases for mysql, mariadb and postgresql - - mysql -u root -e 'CREATE DATABASE test_db;' - - mysql -u root test_db < extensions/database/tests/conf/travis-mysql.sql - - psql -c 'CREATE DATABASE test_db;' -U postgres - - psql -U postgres test_db < extensions/database/tests/conf/travis-pgsql.sql - - cp extensions/database/tests/conf/travis_tests.xml extensions/database/tests/conf/tests.xml - -script: - - mvn jacoco:prepare-agent test - -after_success: - - mvn prepare-package -DskipTests=true - - mvn jacoco:report coveralls:report - -cache: - directories: - - $HOME/.m2 - diff --git a/appveyor.yml b/appveyor.yml index 4d121e259..cc7c369fb 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -26,7 +26,7 @@ before_test: PATH=C:\Program Files\PostgreSQL\9.6\bin\;C:\Program Files\MySQL\MySQL Server 5.7\bin\;%PATH% SET MYSQL_PWD=Password12! mysql -u root --password=Password12! -e "create database test_db;" - mysql -u root test_db --password=Password12! < extensions\database\tests\conf\travis-mysql.sql + mysql -u root test_db --password=Password12! < extensions\database\tests\conf\test-mysql.sql echo "localhost:*:test_db:postgres:Password12!" > C:\Program Files\PostgreSQL\9.6\pgpass.conf echo "localhost:*:test_db:postgres:Password12!" > pgpass.conf echo "localhost:*:test_db:postgres:Password12!" > %userprofile%\pgpass.conf @@ -34,10 +34,10 @@ before_test: SET PGPASSWORD=Password12! SET PGUSER=postgres createdb test_db - psql -U postgres test_db < extensions\database\tests\conf\travis-pgsql.sql + psql -U postgres test_db < extensions\database\tests\conf\test-pgsql.sql copy extensions\database\tests\conf\appveyor_tests.xml extensions\database\tests\conf\tests.xml - copy packaging\travis_pom.xml packaging\pom.xml + copy packaging\test_pom.xml packaging\pom.xml - cmd: |- mvn process-resources mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V diff --git a/extensions/database/tests/conf/appveyor_tests.xml b/extensions/database/tests/conf/appveyor_tests.xml index c913efbed..ecb767fca 100644 --- a/extensions/database/tests/conf/appveyor_tests.xml +++ b/extensions/database/tests/conf/appveyor_tests.xml @@ -18,12 +18,12 @@ - - + + - - - + + + diff --git a/extensions/database/tests/conf/github_actions_tests.xml b/extensions/database/tests/conf/github_actions_tests.xml new file mode 100644 index 000000000..3e508e567 --- /dev/null +++ b/extensions/database/tests/conf/github_actions_tests.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/database/tests/conf/travis-mariadb.sql b/extensions/database/tests/conf/test-mariadb.sql similarity index 100% rename from extensions/database/tests/conf/travis-mariadb.sql rename to extensions/database/tests/conf/test-mariadb.sql diff --git a/extensions/database/tests/conf/travis-mysql.sql b/extensions/database/tests/conf/test-mysql.sql similarity index 100% rename from extensions/database/tests/conf/travis-mysql.sql rename to extensions/database/tests/conf/test-mysql.sql diff --git a/extensions/database/tests/conf/travis-pgsql.sql b/extensions/database/tests/conf/test-pgsql.sql similarity index 100% rename from extensions/database/tests/conf/travis-pgsql.sql rename to extensions/database/tests/conf/test-pgsql.sql diff --git a/extensions/database/tests/conf/travis-sqlite.sql b/extensions/database/tests/conf/test-sqlite.sql similarity index 100% rename from extensions/database/tests/conf/travis-sqlite.sql rename to extensions/database/tests/conf/test-sqlite.sql diff --git a/extensions/database/tests/conf/tests.xml b/extensions/database/tests/conf/tests.xml index 726ccf615..1bce40929 100644 --- a/extensions/database/tests/conf/tests.xml +++ b/extensions/database/tests/conf/tests.xml @@ -22,12 +22,12 @@ - - + + - - - + + + diff --git a/extensions/database/tests/conf/travis_tests.xml b/extensions/database/tests/conf/travis_tests.xml deleted file mode 100644 index ee5c9c6eb..000000000 --- a/extensions/database/tests/conf/travis_tests.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/extensions/database/tests/src/com/google/refine/extension/database/DatabaseTestConfig.java b/extensions/database/tests/src/com/google/refine/extension/database/DatabaseTestConfig.java index 5f1c3d225..c9c9a46c3 100644 --- a/extensions/database/tests/src/com/google/refine/extension/database/DatabaseTestConfig.java +++ b/extensions/database/tests/src/com/google/refine/extension/database/DatabaseTestConfig.java @@ -21,7 +21,7 @@ public class DatabaseTestConfig extends DBExtensionTests { @BeforeSuite @Parameters({ "mySqlDbName", "mySqlDbHost", "mySqlDbPort", "mySqlDbUser", "mySqlDbPassword", "mySqlTestTable", "pgSqlDbName", "pgSqlDbHost", "pgSqlDbPort", "pgSqlDbUser", "pgSqlDbPassword", "pgSqlTestTable", - "mariadbDbName", "mariadbDbHost", "mariadbDbPort", "mariadbyDbUser", "mariadbDbPassword", "mariadbTestTable", + "mariadbDbName", "mariadbDbHost", "mariadbDbPort", "mariadbDbUser", "mariadbDbPassword", "mariadbTestTable", "sqliteDbName", "sqliteTestTable"}) public void beforeSuite( @Optional(DEFAULT_MYSQL_DB_NAME) String mySqlDbName, @Optional(DEFAULT_MYSQL_HOST) String mySqlDbHost, @@ -33,7 +33,7 @@ public class DatabaseTestConfig extends DBExtensionTests { @Optional(DEFAULT_PGSQL_PASSWORD) String pgSqlDbPassword, @Optional(DEFAULT_TEST_TABLE) String pgSqlTestTable, @Optional(DEFAULT_MARIADB_NAME) String mariadbDbName, @Optional(DEFAULT_MARIADB_HOST) String mariadbDbHost, - @Optional(DEFAULT_MARIADB_PORT) String mariadbDbPort, @Optional(DEFAULT_MARIADB_USER) String mariadbyDbUser, + @Optional(DEFAULT_MARIADB_PORT) String mariadbDbPort, @Optional(DEFAULT_MARIADB_USER) String mariadbDbUser, @Optional(DEFAULT_MARIADB_PASSWORD) String mariadbDbPassword, @Optional(DEFAULT_TEST_TABLE) String mariadbTestTable, @Optional(DEFAULT_SQLITE_DB_NAME) String sqliteDbName, @Optional(DEFAULT_TEST_TABLE) String sqliteTestTable) @@ -64,7 +64,7 @@ public class DatabaseTestConfig extends DBExtensionTests { mariadbDbConfig.setDatabasePassword(mariadbDbPassword); mariadbDbConfig.setDatabasePort(Integer.parseInt(mariadbDbPort)); mariadbDbConfig.setDatabaseType(MariaDBDatabaseService.DB_NAME); - mariadbDbConfig.setDatabaseUser(mariadbyDbUser); + mariadbDbConfig.setDatabaseUser(mariadbDbUser); mariadbDbConfig.setUseSSL(false); sqliteDbConfig = new DatabaseConfiguration(); diff --git a/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBConnectionManagerTest.java b/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBConnectionManagerTest.java index ba1aeb849..b63875abd 100644 --- a/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBConnectionManagerTest.java +++ b/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBConnectionManagerTest.java @@ -24,7 +24,7 @@ public class MariaDBConnectionManagerTest extends DBExtensionTests { @BeforeTest - @Parameters({ "mariaDbName", "mariaDbHost", "mariaDbPort", "mariaDbUser", "mariaDbPassword", "mariaDbTestTable"}) + @Parameters({ "mariadbDbName", "mariadbDbHost", "mariadbDbPort", "mariadbDbUser", "mariadbDbPassword", "mariaTestTable"}) public void beforeTest(@Optional(DEFAULT_MARIADB_NAME) String mariaDbName, @Optional(DEFAULT_MARIADB_HOST) String mariaDbHost, @Optional(DEFAULT_MARIADB_PORT) String mariaDbPort, @Optional(DEFAULT_MARIADB_USER) String mariaDbUser, @Optional(DEFAULT_MARIADB_PASSWORD) String mariaDbPassword, @Optional(DEFAULT_TEST_TABLE) String mariaDbTestTable) { diff --git a/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBDatabaseServiceTest.java b/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBDatabaseServiceTest.java index dc1373401..3debead77 100644 --- a/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBDatabaseServiceTest.java +++ b/extensions/database/tests/src/com/google/refine/extension/database/mariadb/MariaDBDatabaseServiceTest.java @@ -30,7 +30,7 @@ public class MariaDBDatabaseServiceTest extends DBExtensionTests{ @BeforeTest - @Parameters({ "mariaDbName", "mariaDbHost", "mariaDbPort", "mariaDbUser", "mariaDbPassword", "mariaDbTestTable"}) + @Parameters({ "mariadbDbName", "mariadbDbHost", "mariadbDbPort", "mariadbDbUser", "mariadbDbPassword", "mariadbTestTable"}) public void beforeTest(@Optional(DEFAULT_MARIADB_NAME) String mariaDbName, @Optional(DEFAULT_MARIADB_HOST) String mariaDbHost, @Optional(DEFAULT_MARIADB_PORT) String mariaDbPort, @Optional(DEFAULT_MARIADB_USER) String mariaDbUser, @Optional(DEFAULT_MARIADB_PASSWORD) String mariaDbPassword, @Optional(DEFAULT_TEST_TABLE) String mariaDbTestTable) { diff --git a/packaging/travis_pom.xml b/packaging/test_pom.xml similarity index 100% rename from packaging/travis_pom.xml rename to packaging/test_pom.xml From 060375b0076ab593a74d2615f627cb63b3f26f08 Mon Sep 17 00:00:00 2001 From: Antonin Delpeuch Date: Fri, 4 Dec 2020 09:01:09 +0100 Subject: [PATCH 02/20] Normalize case of documentation image extensions --- ...{expression-editor.PNG => expression-editor.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/static/img/{expression-editor.PNG => expression-editor.png} (100%) diff --git a/docs/static/img/expression-editor.PNG b/docs/static/img/expression-editor.png similarity index 100% rename from docs/static/img/expression-editor.PNG rename to docs/static/img/expression-editor.png From 9e94d32b49d50c1cf155eedb94a56bcfae9c49b2 Mon Sep 17 00:00:00 2001 From: Antonin Delpeuch Date: Mon, 7 Dec 2020 05:36:05 +0100 Subject: [PATCH 03/20] GitHub actions migration followup (#3386) * Update CI badge in README.md after #3378 * Fix permissions issue with Coveralls token * Run jobs for Java 8 and 14. Temporarily re-hook the workflow to the pull_request event to test the setup. * Publish coverage for master builds too --- .github/workflows/pull_request.yml | 9 +++-- .github/workflows/snapshot_release.yml | 46 +++++++++++++++++++++++++- README.md | 2 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a9509f2a5..aff51029b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,9 +1,12 @@ name: Java CI -on: [pull_request] +on: [pull_request_target] jobs: build: + strategy: + matrix: + java: [ 8, 14 ] runs-on: ubuntu-latest @@ -44,10 +47,10 @@ jobs: restore-keys: | ${{ runner.os }}-maven- - - name: Set up JDK 1.8 + - name: Set up Java ${{ matrix.java }} uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: ${{ matrix.java }} - name: Configure connections to databases id: configure_db_connections diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 76eb2972e..0d8da984f 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -8,6 +8,32 @@ on: jobs: build: + services: + postgres: + image: postgres + ports: + - 5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 'postgres' + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mysql: + image: mysql:8 + ports: + - 3306 + env: + MYSQL_ROOT_PASSWORD: root + options: >- + --health-cmd "mysqladmin ping" + --health-interval 5s + --health-timeout 2s + --health-retries 3 + runs-on: ubuntu-latest steps: @@ -31,8 +57,26 @@ jobs: - name: Install genisoimage and jq run: sudo apt-get install genisoimage jq + - name: Configure connections to databases + id: configure_db_connections + run: cat extensions/database/tests/conf/github_actions_tests.xml | sed -e "s/MYSQL_PORT/${{ job.services.mysql.ports[3306] }}/g" | sed -e "s/POSTGRES_PORT/${{ job.services.postgres.ports[5432] }}/g" > extensions/database/tests/conf/tests.xml + + - name: Populate databases with test data + id: populate_databases_with_test_data + run: | + mysql -u root -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} -proot -e 'CREATE DATABASE test_db;' + mysql -u root -h 127.0.0.1 -P ${{ job.services.mysql.ports[3306] }} -proot < extensions/database/tests/conf/test-mysql.sql + psql -U postgres test_db -h 127.0.0.1 -p ${{ job.services.postgres.ports[5432] }} < extensions/database/tests/conf/test-pgsql.sql + env: + PGPASSWORD: postgres + - name: Build and test with Maven - run: ./refine test + run: mvn jacoco:prepare-agent test + + - name: Submit test coverage to Coveralls + run: | + mvn prepare-package -DskipTests=true + mvn jacoco:report coveralls:report -DrepoToken=${{ secrets.COVERALLS_TOKEN }} -DpullRequest=${{ github.event.number }} -DserviceName="GitHub Actions" -DserviceBuildNumber=${{ env.GITHUB_RUN_ID }} -Dbranch=master - name: Get the OpenRefine snapshot version run: echo ::set-env name=OR_VERSION::$(cat ./main/webapp/WEB-INF/classes/git.properties | jq -r '.["git.commit.id.describe"]') diff --git a/README.md b/README.md index 8eb15e1ce..1fef10a3b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenRefine -[![Join the chat at https://gitter.im/OpenRefine/OpenRefine](https://badges.gitter.im/OpenRefine/OpenRefine.svg)](https://gitter.im/OpenRefine/OpenRefine?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.com/OpenRefine/OpenRefine.svg?branch=master)](https://travis-ci.com/OpenRefine/OpenRefine) [![Coverage Status](https://coveralls.io/repos/github/OpenRefine/OpenRefine/badge.svg?branch=master)](https://coveralls.io/github/OpenRefine/OpenRefine?branch=master) [![Translation progress](https://hosted.weblate.org/widgets/openrefine/-/svg-badge.svg)](https://hosted.weblate.org/engage/openrefine/?utm_source=widget) [![Total alerts](https://img.shields.io/lgtm/alerts/g/OpenRefine/OpenRefine.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/OpenRefine/OpenRefine/alerts/) +[![Join the chat at https://gitter.im/OpenRefine/OpenRefine](https://badges.gitter.im/OpenRefine/OpenRefine.svg)](https://gitter.im/OpenRefine/OpenRefine) ![Java CI](https://github.com/OpenRefine/OpenRefine/workflows/Java%20CI/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/OpenRefine/OpenRefine/badge.svg?branch=master)](https://coveralls.io/github/OpenRefine/OpenRefine?branch=master) [![Translation progress](https://hosted.weblate.org/widgets/openrefine/-/svg-badge.svg)](https://hosted.weblate.org/engage/openrefine/?utm_source=widget) [![Total alerts](https://img.shields.io/lgtm/alerts/g/OpenRefine/OpenRefine.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/OpenRefine/OpenRefine/alerts/) OpenRefine is a Java-based power tool that allows you to load data, understand it, clean it up, reconcile it, and augment it with data coming from From 14f43dc2cccab09eacf5d9771110cf08e579d82b Mon Sep 17 00:00:00 2001 From: Tom Morris Date: Mon, 7 Dec 2020 00:38:36 -0500 Subject: [PATCH 04/20] Refactor HTTP code into common module & Improve Fetch URL - fixes #3129 (#3237) * Refactor HTTP code into a common utility class Centralizes the six (slightly) different implementations to use a common Apache HTTP Client 5 implementation which implements our strategies for retries, timeouts, error handling, etc. Apache HTTP Client 5 adds support for Retry-After headers, HTTP/2, and a bunch of other stuff under the covers. Moves request delay to a request interceptor and fixes calculation of the delay (again). Increase retries from 1x to 3x and use delay*2 as the default retry interval, if no Retry-After header. Uses an exponential backoff strategy for multiple retries. * Reuses HTTP client across requests * Use IOException instead of Exception for HTTP errors --- main/pom.xml | 5 + .../recon/GuessTypesOfColumnCommand.java | 97 +++----- .../refine/importing/ImportingUtilities.java | 123 +++++------ .../recon/ReconciledDataExtensionJob.java | 77 ++----- .../model/recon/StandardReconConfig.java | 110 ++++----- ...ColumnAdditionByFetchingURLsOperation.java | 120 ++-------- .../com/google/refine/util/HttpClient.java | 208 ++++++++++++++++++ .../importing/ImportingUtilitiesTests.java | 9 +- .../model/recon/StandardReconConfigTests.java | 2 +- ...nAdditionByFetchingURLsOperationTests.java | 103 ++++++++- .../recon/ExtendDataOperationTests.java | 8 +- 11 files changed, 490 insertions(+), 372 deletions(-) create mode 100644 main/src/com/google/refine/util/HttpClient.java diff --git a/main/pom.xml b/main/pom.xml index dc4930f16..1e22434d5 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -290,6 +290,11 @@ clojure 1.10.1 + + org.apache.httpcomponents.client5 + httpclient5 + 5.0.2 + org.apache.httpcomponents httpclient diff --git a/main/src/com/google/refine/commands/recon/GuessTypesOfColumnCommand.java b/main/src/com/google/refine/commands/recon/GuessTypesOfColumnCommand.java index 816c56acf..8d3ce9226 100644 --- a/main/src/com/google/refine/commands/recon/GuessTypesOfColumnCommand.java +++ b/main/src/com/google/refine/commands/recon/GuessTypesOfColumnCommand.java @@ -48,19 +48,6 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.http.Consts; -import org.apache.http.NameValuePair; -import org.apache.http.StatusLine; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.client.LaxRedirectStrategy; -import org.apache.http.message.BasicNameValuePair; - import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; @@ -68,7 +55,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.refine.RefineServlet; import com.google.refine.commands.Command; import com.google.refine.expr.ExpressionUtils; import com.google.refine.model.Column; @@ -76,6 +62,7 @@ import com.google.refine.model.Project; import com.google.refine.model.ReconType; import com.google.refine.model.Row; import com.google.refine.model.recon.StandardReconConfig.ReconResult; +import com.google.refine.util.HttpClient; import com.google.refine.util.ParsingUtilities; public class GuessTypesOfColumnCommand extends Command { @@ -180,61 +167,38 @@ public class GuessTypesOfColumnCommand extends Command { } String queriesString = ParsingUtilities.defaultWriter.writeValueAsString(queryMap); + String responseString; try { - RequestConfig defaultRequestConfig = RequestConfig.custom() - .setConnectTimeout(30 * 1000) - .build(); + responseString = postQueries(serviceUrl, queriesString); + ObjectNode o = ParsingUtilities.evaluateJsonStringToObjectNode(responseString); - HttpClientBuilder httpClientBuilder = HttpClients.custom() - .setUserAgent(RefineServlet.getUserAgent()) - .setRedirectStrategy(new LaxRedirectStrategy()) - .setDefaultRequestConfig(defaultRequestConfig); - - CloseableHttpClient httpClient = httpClientBuilder.build(); - HttpPost request = new HttpPost(serviceUrl); - List body = Collections.singletonList( - new BasicNameValuePair("queries", queriesString)); - request.setEntity(new UrlEncodedFormEntity(body, Consts.UTF_8)); - - try (CloseableHttpResponse response = httpClient.execute(request)) { - StatusLine statusLine = response.getStatusLine(); - if (statusLine.getStatusCode() >= 400) { - throw new IOException("Failed - code:" - + Integer.toString(statusLine.getStatusCode()) - + " message: " + statusLine.getReasonPhrase()); + Iterator iterator = o.iterator(); + while (iterator.hasNext()) { + JsonNode o2 = iterator.next(); + if (!(o2.has("result") && o2.get("result") instanceof ArrayNode)) { + continue; } - - String s = ParsingUtilities.inputStreamToString(response.getEntity().getContent()); - ObjectNode o = ParsingUtilities.evaluateJsonStringToObjectNode(s); - Iterator iterator = o.iterator(); - while (iterator.hasNext()) { - JsonNode o2 = iterator.next(); - if (!(o2.has("result") && o2.get("result") instanceof ArrayNode)) { - continue; - } + ArrayNode results = (ArrayNode) o2.get("result"); + List reconResults = ParsingUtilities.mapper.convertValue(results, new TypeReference>() {}); + int count = reconResults.size(); - ArrayNode results = (ArrayNode) o2.get("result"); - List reconResults = ParsingUtilities.mapper.convertValue(results, new TypeReference>() {}); - int count = reconResults.size(); + for (int j = 0; j < count; j++) { + ReconResult result = reconResults.get(j); + double score = 1.0 / (1 + j); // score by each result's rank - for (int j = 0; j < count; j++) { - ReconResult result = reconResults.get(j); - double score = 1.0 / (1 + j); // score by each result's rank + List types = result.types; + int typeCount = types.size(); - List types = result.types; - int typeCount = types.size(); - - for (int t = 0; t < typeCount; t++) { - ReconType type = types.get(t); - double score2 = score * (typeCount - t) / typeCount; - if (map.containsKey(type.id)) { - TypeGroup tg = map.get(type.id); - tg.score += score2; - tg.count++; - } else { - map.put(type.id, new TypeGroup(type.id, type.name, score2)); - } + for (int t = 0; t < typeCount; t++) { + ReconType type = types.get(t); + double score2 = score * (typeCount - t) / typeCount; + if (map.containsKey(type.id)) { + TypeGroup tg = map.get(type.id); + tg.score += score2; + tg.count++; + } else { + map.put(type.id, new TypeGroup(type.id, type.name, score2)); } } } @@ -243,7 +207,7 @@ public class GuessTypesOfColumnCommand extends Command { logger.error("Failed to guess cell types for load\n" + queriesString, e); throw e; } - + List types = new ArrayList(map.values()); Collections.sort(types, new Comparator() { @Override @@ -258,7 +222,12 @@ public class GuessTypesOfColumnCommand extends Command { return types; } - + + private String postQueries(String serviceUrl, String queriesString) throws IOException { + HttpClient client = new HttpClient(); + return client.postNameValue(serviceUrl, "queries", queriesString); + } + static protected class TypeGroup { @JsonProperty("id") protected String id; diff --git a/main/src/com/google/refine/importing/ImportingUtilities.java b/main/src/com/google/refine/importing/ImportingUtilities.java index b2eb3bc19..8cba7db6b 100644 --- a/main/src/com/google/refine/importing/ImportingUtilities.java +++ b/main/src/com/google/refine/importing/ImportingUtilities.java @@ -69,19 +69,13 @@ import org.apache.commons.fileupload.ProgressListener; import org.apache.commons.fileupload.disk.DiskFileItemFactory; import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.apache.commons.fileupload.util.Streams; -import org.apache.http.HttpEntity; -import org.apache.http.auth.AuthScope; -import org.apache.http.auth.UsernamePasswordCredentials; -import org.apache.http.client.CredentialsProvider; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.entity.ContentType; -import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.util.EntityUtils; -import org.apache.http.StatusLine; +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,10 +83,10 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.refine.ProjectManager; import com.google.refine.ProjectMetadata; -import com.google.refine.RefineServlet; import com.google.refine.importing.ImportingManager.Format; import com.google.refine.importing.UrlRewriter.Result; import com.google.refine.model.Project; +import com.google.refine.util.HttpClient; import com.google.refine.util.JSONUtilities; import com.google.refine.util.ParsingUtilities; import java.util.stream.Collectors; @@ -287,65 +281,56 @@ public class ImportingUtilities { } if ("http".equals(url.getProtocol()) || "https".equals(url.getProtocol())) { - HttpClientBuilder clientbuilder = HttpClients.custom() - .setUserAgent(RefineServlet.getUserAgent()); -// .setConnectionBackoffStrategy(ConnectionBackoffStrategy) + final URL lastUrl = url; + final HttpClientResponseHandler responseHandler = new HttpClientResponseHandler() { - String userinfo = url.getUserInfo(); - // HTTPS only - no sending password in the clear over HTTP - if ("https".equals(url.getProtocol()) && userinfo != null) { - int s = userinfo.indexOf(':'); - if (s > 0) { - String user = userinfo.substring(0, s); - String pw = userinfo.substring(s + 1, userinfo.length()); - CredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(new AuthScope(url.getHost(), 443), - new UsernamePasswordCredentials(user, pw)); - clientbuilder = clientbuilder.setDefaultCredentialsProvider(credsProvider); - } - } + @Override + public String handleResponse(final ClassicHttpResponse response) throws IOException { + final int status = response.getCode(); + if (status >= HttpStatus.SC_SUCCESS && status < HttpStatus.SC_REDIRECTION) { + final HttpEntity entity = response.getEntity(); + if (entity == null) { + throw new IOException("No content found in " + lastUrl.toExternalForm()); + } - CloseableHttpClient httpclient = clientbuilder.build(); - HttpGet httpGet = new HttpGet(url.toURI()); - CloseableHttpResponse response = httpclient.execute(httpGet); + try { + InputStream stream2 = entity.getContent(); - try { - HttpEntity entity = response.getEntity(); - if (entity == null) { - throw new Exception("No content found in " + url.toString()); - } - StatusLine status = response.getStatusLine(); - int statusCode = response.getStatusLine().getStatusCode(); - if (statusCode >= 400) { - String errorString = ParsingUtilities.inputStreamToString(entity.getContent()); - String message = String.format("HTTP error %d : %s | %s", statusCode, - status.getReasonPhrase(), errorString); - throw new Exception(message); - } - InputStream stream2 = entity.getContent(); + String mimeType = null; + String charset = null; + ContentType contentType = ContentType.parse(entity.getContentType()); + if (contentType != null) { + mimeType = contentType.getMimeType(); + Charset cs = contentType.getCharset(); + if (cs != null) { + charset = cs.toString(); + } + } + JSONUtilities.safePut(fileRecord, "declaredMimeType", mimeType); + JSONUtilities.safePut(fileRecord, "declaredEncoding", charset); + if (saveStream(stream2, lastUrl, rawDataDir, progress, update, + fileRecord, fileRecords, + entity.getContentLength())) { + return "saved"; // signal to increment archive count + } - String mimeType = null; - String charset = null; - ContentType contentType = ContentType.get(entity); - if (contentType != null) { - mimeType = contentType.getMimeType(); - Charset cs = contentType.getCharset(); - if (cs != null) { - charset = cs.toString(); + } catch (final IOException ex) { + throw new ClientProtocolException(ex); + } + return null; + } else { + // String errorBody = EntityUtils.toString(response.getEntity()); + throw new ClientProtocolException(String.format("HTTP error %d : %s for URL %s", status, + response.getReasonPhrase(), lastUrl.toExternalForm())); } } - JSONUtilities.safePut(fileRecord, "declaredMimeType", mimeType); - JSONUtilities.safePut(fileRecord, "declaredEncoding", charset); - if (saveStream(stream2, url, rawDataDir, progress, update, - fileRecord, fileRecords, - entity.getContentLength())) { - archiveCount++; - } - downloadCount++; - EntityUtils.consume(entity); - } finally { - httpGet.reset(); - } + }; + + HttpClient httpClient = new HttpClient(); + if (httpClient.getResponse(urlString, null, responseHandler) != null) { + archiveCount++; + }; + downloadCount++; } else { // Fallback handling for non HTTP connections (only FTP?) URLConnection urlConnection = url.openConnection(); @@ -418,7 +403,7 @@ public class ImportingUtilities { private static boolean saveStream(InputStream stream, URL url, File rawDataDir, final Progress progress, final SavingUpdate update, ObjectNode fileRecord, ArrayNode fileRecords, long length) - throws IOException, Exception { + throws IOException { String localname = url.getPath(); if (localname.isEmpty() || localname.endsWith("/")) { localname = localname + "temp"; @@ -436,7 +421,7 @@ public class ImportingUtilities { long actualLength = saveStreamToFile(stream, file, update); JSONUtilities.safePut(fileRecord, "size", actualLength); if (actualLength == 0) { - throw new Exception("No content found in " + url.toString()); + throw new IOException("No content found in " + url.toString()); } else if (length >= 0) { update.totalExpectedSize += (actualLength - length); } else { diff --git a/main/src/com/google/refine/model/recon/ReconciledDataExtensionJob.java b/main/src/com/google/refine/model/recon/ReconciledDataExtensionJob.java index 86020c541..d88a96082 100644 --- a/main/src/com/google/refine/model/recon/ReconciledDataExtensionJob.java +++ b/main/src/com/google/refine/model/recon/ReconciledDataExtensionJob.java @@ -37,30 +37,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package com.google.refine.model.recon; import java.io.IOException; -import java.io.InputStream; import java.io.StringWriter; import java.io.Writer; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import org.apache.http.Consts; -import org.apache.http.NameValuePair; -import org.apache.http.StatusLine; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.client.LaxRedirectStrategy; -import org.apache.http.message.BasicNameValuePair; - import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -69,14 +54,15 @@ import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.refine.RefineServlet; import com.google.refine.expr.functions.ToDate; import com.google.refine.model.ReconCandidate; import com.google.refine.model.ReconType; +import com.google.refine.util.HttpClient; import com.google.refine.util.JSONUtilities; import com.google.refine.util.JsonViews; import com.google.refine.util.ParsingUtilities; + public class ReconciledDataExtensionJob { @@ -170,15 +156,23 @@ public class ReconciledDataExtensionJob { final public DataExtensionConfig extension; final public String endpoint; final public List columns = new ArrayList(); - + // not final: initialized lazily - private static CloseableHttpClient httpClient = null; - + private static HttpClient httpClient = null; + public ReconciledDataExtensionJob(DataExtensionConfig obj, String endpoint) { this.extension = obj; this.endpoint = endpoint; } - + + /** + * @todo Although the HTTP code has been unified, there may still be opportunity + * to refactor a higher level querying library out of this which could be shared + * with StandardReconConfig + * + * It may also be possible to extract a library to query reconciliation services + * which could be used outside of OpenRefine. + */ public Map extend( Set ids, Map reconCandidateMap @@ -187,7 +181,7 @@ public class ReconciledDataExtensionJob { formulateQuery(ids, extension, writer); String query = writer.toString(); - String response = performQuery(this.endpoint, query); + String response = postExtendQuery(this.endpoint, query); ObjectNode o = ParsingUtilities.mapper.readValue(response, ObjectNode.class); @@ -218,46 +212,17 @@ public class ReconciledDataExtensionJob { return map; } - /** - * @todo this should be refactored to be unified with the HTTP querying code - * from StandardReconConfig. We should ideally extract a library to query - * reconciliation services and expose it as such for others to reuse. - */ - - static protected String performQuery(String endpoint, String query) throws IOException { - HttpPost request = new HttpPost(endpoint); - List body = Collections.singletonList( - new BasicNameValuePair("extend", query)); - request.setEntity(new UrlEncodedFormEntity(body, Consts.UTF_8)); - - try (CloseableHttpResponse response = getHttpClient().execute(request)) { - StatusLine statusLine = response.getStatusLine(); - if (statusLine.getStatusCode() >= 400) { - throw new IOException("Data extension query failed - code: " - + Integer.toString(statusLine.getStatusCode()) - + " message: " + statusLine.getReasonPhrase()); - } else { - return ParsingUtilities.inputStreamToString(response.getEntity().getContent()); - } - } + static protected String postExtendQuery(String endpoint, String query) throws IOException { + return getHttpClient().postNameValue(endpoint, "extend", query); } - private static CloseableHttpClient getHttpClient() { - if (httpClient != null) { - return httpClient; + private static HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new HttpClient(); } - RequestConfig defaultRequestConfig = RequestConfig.custom() - .setConnectTimeout(30 * 1000) - .build(); - - HttpClientBuilder httpClientBuilder = HttpClients.custom() - .setUserAgent(RefineServlet.getUserAgent()) - .setRedirectStrategy(new LaxRedirectStrategy()) - .setDefaultRequestConfig(defaultRequestConfig); - httpClient = httpClientBuilder.build(); return httpClient; } - + protected ReconciledDataExtensionJob.DataExtension collectResult( ObjectNode record, Map reconCandidateMap diff --git a/main/src/com/google/refine/model/recon/StandardReconConfig.java b/main/src/com/google/refine/model/recon/StandardReconConfig.java index c27e79915..e4d888532 100644 --- a/main/src/com/google/refine/model/recon/StandardReconConfig.java +++ b/main/src/com/google/refine/model/recon/StandardReconConfig.java @@ -45,18 +45,6 @@ import java.util.Map; import java.util.Set; import org.apache.commons.lang.StringUtils; -import org.apache.http.Consts; -import org.apache.http.NameValuePair; -import org.apache.http.StatusLine; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.client.LaxRedirectStrategy; -import org.apache.http.message.BasicNameValuePair; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -69,7 +57,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.refine.RefineServlet; import com.google.refine.expr.ExpressionUtils; import com.google.refine.model.Cell; import com.google.refine.model.Project; @@ -79,6 +66,7 @@ import com.google.refine.model.ReconCandidate; import com.google.refine.model.ReconType; import com.google.refine.model.RecordModel.RowDependency; import com.google.refine.model.Row; +import com.google.refine.util.HttpClient; import com.google.refine.util.ParsingUtilities; public class StandardReconConfig extends ReconConfig { @@ -164,7 +152,7 @@ public class StandardReconConfig extends ReconConfig { final private int limit; // initialized lazily - private CloseableHttpClient httpClient = null; + private HttpClient httpClient = null; @JsonCreator public StandardReconConfig( @@ -434,29 +422,29 @@ public class StandardReconConfig extends ReconConfig { try { job.code = ParsingUtilities.defaultWriter.writeValueAsString(query); } catch (JsonProcessingException e) { + // FIXME: This error will get lost e.printStackTrace(); return null; // TODO: Throw exception instead? } return job; } - private CloseableHttpClient getHttpClient() { - if (httpClient != null) { - return httpClient; + private HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new HttpClient(); } - RequestConfig defaultRequestConfig = RequestConfig.custom() - .setConnectTimeout(30 * 1000) - .setSocketTimeout(60 * 1000) - .build(); - - HttpClientBuilder httpClientBuilder = HttpClients.custom() - .setUserAgent(RefineServlet.getUserAgent()) - .setRedirectStrategy(new LaxRedirectStrategy()) - .setDefaultRequestConfig(defaultRequestConfig); - httpClient = httpClientBuilder.build(); return httpClient; } - + + private String postQueries(String url, String queriesString) throws IOException { + try { + return getHttpClient().postNameValue(url, "queries", queriesString); + + } catch (IOException e) { + throw new IOException("Failed to batch recon with load:\n" + queriesString, e); + } + } + @Override public List batchRecon(List jobs, long historyEntryID) { List recons = new ArrayList(jobs.size()); @@ -475,51 +463,41 @@ public class StandardReconConfig extends ReconConfig { stringWriter.write("}"); String queriesString = stringWriter.toString(); - HttpPost request = new HttpPost(service); - List body = Collections.singletonList( - new BasicNameValuePair("queries", queriesString)); - request.setEntity(new UrlEncodedFormEntity(body, Consts.UTF_8)); - - try (CloseableHttpResponse response = getHttpClient().execute(request)) { - StatusLine statusLine = response.getStatusLine(); - if (statusLine.getStatusCode() >= 400) { - logger.error("Failed - code: " - + Integer.toString(statusLine.getStatusCode()) - + " message: " + statusLine.getReasonPhrase()); + try { + String responseString = postQueries(service, queriesString); + ObjectNode o = ParsingUtilities.evaluateJsonStringToObjectNode(responseString); + + if (o == null) { // utility method returns null instead of throwing + logger.error("Failed to parse string as JSON: " + responseString); } else { - String s = ParsingUtilities.inputStreamToString(response.getEntity().getContent()); - ObjectNode o = ParsingUtilities.evaluateJsonStringToObjectNode(s); - if (o == null) { // utility method returns null instead of throwing - logger.error("Failed to parse string as JSON: " + s); - } else { - for (int i = 0; i < jobs.size(); i++) { - StandardReconJob job = (StandardReconJob) jobs.get(i); - Recon recon = null; + for (int i = 0; i < jobs.size(); i++) { + StandardReconJob job = (StandardReconJob) jobs.get(i); + Recon recon = null; - String text = job.text; - String key = "q" + i; - if (o.has(key) && o.get(key) instanceof ObjectNode) { - ObjectNode o2 = (ObjectNode) o.get(key); - if (o2.has("result") && o2.get("result") instanceof ArrayNode) { - ArrayNode results = (ArrayNode) o2.get("result"); + String text = job.text; + String key = "q" + i; + if (o.has(key) && o.get(key) instanceof ObjectNode) { + ObjectNode o2 = (ObjectNode) o.get(key); + if (o2.has("result") && o2.get("result") instanceof ArrayNode) { + ArrayNode results = (ArrayNode) o2.get("result"); - recon = createReconServiceResults(text, results, historyEntryID); - } else { - logger.warn("Service error for text: " + text + "\n Job code: " + job.code + "\n Response: " + o2.toString()); - } + recon = createReconServiceResults(text, results, historyEntryID); } else { // TODO: better error reporting - logger.warn("Service error for text: " + text + "\n Job code: " + job.code); + logger.warn("Service error for text: " + text + "\n Job code: " + job.code + "\n Response: " + o2.toString()); } - - if (recon != null) { - recon.service = service; - } - recons.add(recon); + } else { + // TODO: better error reporting + logger.warn("Service error for text: " + text + "\n Job code: " + job.code); } + + if (recon != null) { + recon.service = service; + } + recons.add(recon); } } - } catch (Exception e) { + } catch (IOException e) { logger.error("Failed to batch recon with load:\n" + queriesString, e); } @@ -535,7 +513,7 @@ public class StandardReconConfig extends ReconConfig { return recons; } - + @Override public Recon createNewRecon(long historyEntryID) { Recon recon = new Recon(historyEntryID, identifierSpace, schemaSpace); @@ -543,7 +521,7 @@ public class StandardReconConfig extends ReconConfig { return recon; } - protected Recon createReconServiceResults(String text, ArrayNode resultsList, long historyEntryID) throws IOException { + protected Recon createReconServiceResults(String text, ArrayNode resultsList, long historyEntryID) { Recon recon = new Recon(historyEntryID, identifierSpace, schemaSpace); List results = ParsingUtilities.mapper.convertValue(resultsList, new TypeReference>() {}); diff --git a/main/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperation.java b/main/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperation.java index a3e5bfc6a..dc95f56dd 100644 --- a/main/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperation.java +++ b/main/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperation.java @@ -37,27 +37,13 @@ import static com.google.common.base.Strings.isNullOrEmpty; import java.io.IOException; import java.io.Serializable; -import java.net.MalformedURLException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import java.util.Properties; import java.util.concurrent.TimeUnit; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.StatusLine; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.entity.ContentType; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.message.BasicHeader; -import org.apache.http.util.EntityUtils; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -65,7 +51,6 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; -import com.google.refine.RefineServlet; import com.google.refine.browsing.Engine; import com.google.refine.browsing.EngineConfig; import com.google.refine.browsing.FilteredRows; @@ -86,7 +71,7 @@ import com.google.refine.operations.EngineDependentOperation; import com.google.refine.operations.OnError; import com.google.refine.process.LongRunningProcess; import com.google.refine.process.Process; -import com.google.refine.util.ParsingUtilities; +import com.google.refine.util.HttpClient; public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperation { @@ -117,8 +102,8 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat final protected boolean _cacheResponses; final protected List _httpHeadersJson; private Header[] httpHeaders = new Header[0]; - final private RequestConfig defaultRequestConfig; - private HttpClientBuilder httpClientBuilder; + private HttpClient _httpClient; + @JsonCreator public ColumnAdditionByFetchingURLsOperation( @@ -163,22 +148,8 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat } } httpHeaders = headers.toArray(httpHeaders); + _httpClient = new HttpClient(_delay); - defaultRequestConfig = RequestConfig.custom() - .setConnectTimeout(30 * 1000) - .setConnectionRequestTimeout(30 * 1000) - .setSocketTimeout(10 * 1000).build(); - - // TODO: Placeholder for future Basic Auth implementation -// CredentialsProvider credsProvider = new BasicCredentialsProvider(); -// credsProvider.setCredentials(new AuthScope(host, 443), -// new UsernamePasswordCredentials(user, password)); - - httpClientBuilder = HttpClients.custom() - .setUserAgent(RefineServlet.getUserAgent()) - .setDefaultRequestConfig(defaultRequestConfig); -// .setConnectionBackoffStrategy(ConnectionBackoffStrategy) -// .setDefaultCredentialsProvider(credsProvider); } @JsonProperty("newColumnName") @@ -281,20 +252,7 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat .build( new CacheLoader() { public Serializable load(String urlString) throws Exception { - Serializable result = fetch(urlString); - try { - // Always sleep for the delay, no matter how long the - // request took. This is more responsible than substracting - // the time spend requesting the URL, because it naturally - // slows us down if the server is busy and takes a long time - // to reply. - if (_delay > 0) { - Thread.sleep(_delay); - } - } catch (InterruptedException e) { - result = null; - } - + Serializable result = fetch(urlString, httpHeaders); if (result == null) { // the load method should not return any null value throw new Exception("null result returned by fetch"); @@ -335,9 +293,9 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat Serializable response = null; if (_urlCache != null) { - response = cachedFetch(urlString); // TODO: Why does this need a separate method? + response = cachedFetch(urlString); } else { - response = fetch(urlString); + response = fetch(urlString, httpHeaders); } if (response != null) { @@ -380,68 +338,19 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat } } - Serializable fetch(String urlString) { - HttpGet httpGet; - - try { - // Use of URL constructor below is purely to get additional error checking to mimic - // previous behavior for the tests. - httpGet = new HttpGet(new URL(urlString).toURI()); - } catch (IllegalArgumentException | MalformedURLException | URISyntaxException e) { - return null; - } - - try { - httpGet.setHeaders(httpHeaders); - httpGet.setConfig(defaultRequestConfig); - - CloseableHttpClient httpclient = httpClientBuilder.build(); - - CloseableHttpResponse response = null; + Serializable fetch(String urlString, Header[] headers) { + try { //HttpClients.createDefault()) { try { - response = httpclient.execute(httpGet); - - HttpEntity entity = response.getEntity(); - if (entity == null) { - throw new Exception("No content found in " + httpGet.getURI().toString()); - } - - String encoding = null; - - if (entity.getContentEncoding() != null) { - encoding = entity.getContentEncoding().getValue(); - } else { - Charset charset = ContentType.getOrDefault(entity).getCharset(); - if (charset != null) { - encoding = charset.name(); - } - } - - String result = ParsingUtilities.inputStreamToString( - entity.getContent(), (encoding == null) || ( encoding.equalsIgnoreCase("\"UTF-8\"")) ? "UTF-8" : encoding); - - EntityUtils.consume(entity); - return result; - + return _httpClient.getAsString(urlString, headers); } catch (IOException e) { - String message; - if (response == null) { - message = "Unknown HTTP error " + e.getLocalizedMessage(); - } else { - StatusLine status = response.getStatusLine(); - HttpEntity errorEntity = response.getEntity(); - String errorString = ParsingUtilities.inputStreamToString(errorEntity.getContent()); - message = String.format("HTTP error %d : %s | %s", status.getStatusCode(), - status.getReasonPhrase(), - errorString); - } - return _onError == OnError.StoreError ? new EvalError(message) : null; + return _onError == OnError.StoreError ? new EvalError(e) : null; } } catch (Exception e) { return _onError == OnError.StoreError ? new EvalError(e.getMessage()) : null; } } + RowVisitor createRowVisitor(List cellsAtRows) { return new RowVisitor() { int cellIndex; @@ -497,4 +406,5 @@ public class ColumnAdditionByFetchingURLsOperation extends EngineDependentOperat }.init(cellsAtRows); } } + } diff --git a/main/src/com/google/refine/util/HttpClient.java b/main/src/com/google/refine/util/HttpClient.java new file mode 100644 index 000000000..d55955b48 --- /dev/null +++ b/main/src/com/google/refine/util/HttpClient.java @@ -0,0 +1,208 @@ +package com.google.refine.util; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.TimeValue; + +import com.google.refine.RefineServlet; + + +public class HttpClient { + final private RequestConfig defaultRequestConfig; + private HttpClientBuilder httpClientBuilder; + private CloseableHttpClient httpClient; + private int _delay; + + public HttpClient() { + this(0); + } + + public HttpClient(int delay) { + _delay = delay; + // Create a connection manager with a custom socket timeout + PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(); + final SocketConfig socketConfig = SocketConfig.custom() + .setSoTimeout(10, TimeUnit.SECONDS) + .build(); + connManager.setDefaultSocketConfig(socketConfig); + + defaultRequestConfig = RequestConfig.custom() + .setConnectTimeout(30, TimeUnit.SECONDS) + .setConnectionRequestTimeout(30, TimeUnit.SECONDS) // TODO: 60 seconds in some places in old code + .build(); + + httpClientBuilder = HttpClients.custom() + .setUserAgent(RefineServlet.getUserAgent()) + .setDefaultRequestConfig(defaultRequestConfig) + .setConnectionManager(connManager) + // Default Apache HC retry is 1x @1 sec (or the value in Retry-Header) + .setRetryStrategy(new ExponentialBackoffRetryStrategy(3, TimeValue.ofMilliseconds(_delay))) +// .setRedirectStrategy(new LaxRedirectStrategy()) // TODO: No longer needed since default doesn't exclude POST? +// .setConnectionBackoffStrategy(ConnectionBackoffStrategy) + .addRequestInterceptorFirst(new HttpRequestInterceptor() { + + private long nextRequestTime = System.currentTimeMillis(); + + @Override + public void process( + final HttpRequest request, + final EntityDetails entity, + final HttpContext context) throws HttpException, IOException { + + long delay = nextRequestTime - System.currentTimeMillis(); + if (delay > 0) { + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + } + } + nextRequestTime = System.currentTimeMillis() + _delay; + + } + }); + + // TODO: Placeholder for future Basic Auth implementation +// String userinfo = url.getUserInfo(); +// // HTTPS only - no sending password in the clear over HTTP +// if ("https".equals(url.getProtocol()) && userinfo != null) { +// int s = userinfo.indexOf(':'); +// if (s > 0) { +// String user = userinfo.substring(0, s); +// String pw = userinfo.substring(s + 1, userinfo.length()); +// CredentialsProvider credsProvider = new BasicCredentialsProvider(); +// credsProvider.setCredentials(new AuthScope(url.getHost(), 443), +// new UsernamePasswordCredentials(user, pw.toCharArray())); +// httpClientBuilder = httpClientBuilder.setDefaultCredentialsProvider(credsProvider); +// } +// } + + httpClient = httpClientBuilder.build(); + } + + public String getAsString(String urlString, Header[] headers) throws IOException { + + final HttpClientResponseHandler responseHandler = new HttpClientResponseHandler() { + + @Override + public String handleResponse(final ClassicHttpResponse response) throws IOException { + final int status = response.getCode(); + if (status >= HttpStatus.SC_SUCCESS && status < HttpStatus.SC_REDIRECTION) { + final HttpEntity entity = response.getEntity(); + if (entity == null) { + throw new IOException("No content found in " + urlString); + } + try { + return EntityUtils.toString(entity); + } catch (final ParseException ex) { + throw new ClientProtocolException(ex); + } + } else { + // String errorBody = EntityUtils.toString(response.getEntity()); + throw new ClientProtocolException(String.format("HTTP error %d : %s for URL %s", status, + response.getReasonPhrase(), urlString)); + } + } + }; + + return getResponse(urlString, headers, responseHandler); + } + + public String getResponse(String urlString, Header[] headers, HttpClientResponseHandler responseHandler) throws IOException { + try { + // Use of URL constructor below is purely to get additional error checking to mimic + // previous behavior for the tests. + new URL(urlString).toURI(); + } catch (IllegalArgumentException | MalformedURLException | URISyntaxException e) { + return null; + } + + HttpGet httpGet = new HttpGet(urlString); + + if (headers != null && headers.length > 0) { + httpGet.setHeaders(headers); + } + httpGet.setConfig(defaultRequestConfig); // FIXME: Redundant? already includes in client builder + return httpClient.execute(httpGet, responseHandler); + } + + public String postNameValue(String serviceUrl, String name, String value) throws IOException { + HttpPost request = new HttpPost(serviceUrl); + List body = Collections.singletonList( + new BasicNameValuePair(name, value)); + request.setEntity(new UrlEncodedFormEntity(body, StandardCharsets.UTF_8)); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + String reasonPhrase = response.getReasonPhrase(); + int statusCode = response.getCode(); + if (statusCode >= 400) { // We should never see 3xx since they get handled automatically + throw new IOException(String.format("HTTP error %d : %s for URL %s", statusCode, reasonPhrase, + request.getRequestUri())); + } + + return ParsingUtilities.inputStreamToString(response.getEntity().getContent()); + } + } + + + /** + * Use binary exponential backoff strategy, instead of the default fixed + * retry interval, if the server doesn't provide a Retry-After time. + */ + class ExponentialBackoffRetryStrategy extends DefaultHttpRequestRetryStrategy { + + private final TimeValue defaultInterval; + + public ExponentialBackoffRetryStrategy(final int maxRetries, final TimeValue defaultRetryInterval) { + super(maxRetries, defaultRetryInterval); + this.defaultInterval = defaultRetryInterval; + } + + @Override + public TimeValue getRetryInterval(HttpResponse response, int execCount, HttpContext context) { + // Get the default implementation's interval + TimeValue interval = super.getRetryInterval(response, execCount, context); + // If it's the same as the default, there was no Retry-After, so use binary + // exponential backoff + if (interval.compareTo(defaultInterval) == 0) { + interval = TimeValue.of(((Double) (Math.pow(2, execCount) * defaultInterval.getDuration())).longValue(), + defaultInterval.getTimeUnit() ); + return interval; + } + return interval; + } + } +} diff --git a/main/tests/server/src/com/google/refine/importing/ImportingUtilitiesTests.java b/main/tests/server/src/com/google/refine/importing/ImportingUtilitiesTests.java index 091178dc0..52bdc5221 100644 --- a/main/tests/server/src/com/google/refine/importing/ImportingUtilitiesTests.java +++ b/main/tests/server/src/com/google/refine/importing/ImportingUtilitiesTests.java @@ -29,6 +29,7 @@ package com.google.refine.importing; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; import java.io.File; import java.io.IOException; @@ -98,8 +99,6 @@ public class ImportingUtilitiesTests extends ImporterTest { public void urlImporting() throws IOException { String RESPONSE_BODY = "{code:401,message:Unauthorised}"; - String MESSAGE = String.format("HTTP error %d : %s | %s", 401, - "Client Error", RESPONSE_BODY); MockWebServer server = new MockWebServer(); MockResponse mockResponse = new MockResponse(); @@ -108,6 +107,8 @@ public class ImportingUtilitiesTests extends ImporterTest { server.start(); server.enqueue(mockResponse); HttpUrl url = server.url("/random"); + String MESSAGE = String.format("HTTP error %d : %s for URL %s", 401, + "Client Error", url); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); StringBody stringBody = new StringBody(url.toString(), ContentType.MULTIPART_FORM_DATA); @@ -145,9 +146,9 @@ public class ImportingUtilitiesTests extends ImporterTest { return job.canceled; } }); - Assert.fail("No Exception was thrown"); + fail("No Exception was thrown"); } catch (Exception exception) { - Assert.assertEquals(MESSAGE, exception.getMessage()); + assertEquals(exception.getMessage(), MESSAGE); } finally { server.close(); } diff --git a/main/tests/server/src/com/google/refine/model/recon/StandardReconConfigTests.java b/main/tests/server/src/com/google/refine/model/recon/StandardReconConfigTests.java index 16ef0efd4..d01a750c9 100644 --- a/main/tests/server/src/com/google/refine/model/recon/StandardReconConfigTests.java +++ b/main/tests/server/src/com/google/refine/model/recon/StandardReconConfigTests.java @@ -91,7 +91,7 @@ public class StandardReconConfigTests extends RefineTest { return wordDistance(s1, s2); } - protected Recon createReconServiceResults(String text, ArrayNode resultsList, long historyEntryID) throws IOException { + protected Recon createReconServiceResults(String text, ArrayNode resultsList, long historyEntryID) { return super.createReconServiceResults(text, resultsList, historyEntryID); } } diff --git a/main/tests/server/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperationTests.java b/main/tests/server/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperationTests.java index e5068af39..f01e673bb 100644 --- a/main/tests/server/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperationTests.java +++ b/main/tests/server/src/com/google/refine/operations/column/ColumnAdditionByFetchingURLsOperationTests.java @@ -33,6 +33,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package com.google.refine.operations.column; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -129,7 +132,7 @@ public class ColumnAdditionByFetchingURLsOperationTests extends RefineTest { } catch (InterruptedException e) { Assert.fail("Test interrupted"); } - Assert.assertFalse(process.isRunning()); + Assert.assertFalse(process.isRunning(),"Process failed to complete within timeout " + timeout); } @Test @@ -273,4 +276,102 @@ public class ColumnAdditionByFetchingURLsOperationTests extends RefineTest { } } + @Test + public void testRetries() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.start(); + HttpUrl url = server.url("/retries"); + + for (int i = 0; i < 2; i++) { + Row row = new Row(2); + row.setCell(0, new Cell("test" + (i + 1), null)); + project.rows.add(row); + } + + // Queue 5 error responses with 1 sec. Retry-After interval + for (int i = 0; i < 5; i++) { + server.enqueue(new MockResponse() + .setHeader("Retry-After", 1) + .setResponseCode(429) + .setBody(Integer.toString(i,10))); + } + + server.enqueue(new MockResponse().setBody("success")); + + EngineDependentOperation op = new ColumnAdditionByFetchingURLsOperation(engine_config, + "fruits", + "\"" + url + "?city=\"+value", + OnError.StoreError, + "rand", + 1, + 100, + false, + null); + + // 6 requests (4 retries @1 sec) + final response + long start = System.currentTimeMillis(); + runAndWait(op, 4500); + + // Make sure that our Retry-After headers were obeyed (4*1 sec vs 4*100msec) + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed > 4000, "Retry-After retries didn't take long enough - elapsed = " + elapsed ); + + // 1st row fails after 4 tries (3 retries), 2nd row tries twice and gets value + assertTrue(project.rows.get(0).getCellValue(1).toString().contains("HTTP error 429"), "missing 429 error"); + assertEquals(project.rows.get(1).getCellValue(1).toString(), "success"); + + server.shutdown(); + } + } + + @Test + public void testExponentialRetries() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.start(); + HttpUrl url = server.url("/retries"); + + for (int i = 0; i < 3; i++) { + Row row = new Row(2); + row.setCell(0, new Cell("test" + (i + 1), null)); + project.rows.add(row); + } + + // Use 503 Server Unavailable with no Retry-After header this time + for (int i = 0; i < 5; i++) { + server.enqueue(new MockResponse() + .setResponseCode(503) + .setBody(Integer.toString(i,10))); + } + server.enqueue(new MockResponse().setBody("success")); + + server.enqueue(new MockResponse().setBody("not found").setResponseCode(404)); + + ColumnAdditionByFetchingURLsOperation op = new ColumnAdditionByFetchingURLsOperation(engine_config, + "fruits", + "\"" + url + "?city=\"+value", + OnError.StoreError, + "rand", + 1, + 100, + false, + null); + + // 6 requests (4 retries 200, 400, 800, 200 msec) + final response + long start = System.currentTimeMillis(); + runAndWait(op, 2500); + + // Make sure that our exponential back off is working + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed > 1600, "Exponential retries didn't take enough time - elapsed = " + elapsed); + + // 1st row fails after 4 tries (3 retries), 2nd row tries twice and gets value, 3rd row is hard error + assertTrue(project.rows.get(0).getCellValue(1).toString().contains("HTTP error 503"), "Missing 503 error"); + assertEquals(project.rows.get(1).getCellValue(1).toString(), "success"); + assertTrue(project.rows.get(2).getCellValue(1).toString().contains("HTTP error 404"),"Missing 404 error"); + + server.shutdown(); + } + } + + } diff --git a/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java b/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java index 5a52537ef..123d9311b 100644 --- a/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java +++ b/main/tests/server/src/com/google/refine/operations/recon/ExtendDataOperationTests.java @@ -38,9 +38,7 @@ import static org.mockito.Mockito.mock; import static org.powermock.api.mockito.PowerMockito.mockStatic; import java.io.IOException; -import java.io.InputStream; import java.io.StringWriter; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -49,7 +47,6 @@ import java.util.Map; import java.util.Properties; import java.util.Set; -import org.apache.commons.io.IOUtils; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.powermock.api.mockito.PowerMockito; @@ -225,7 +222,6 @@ public class ExtendDataOperationTests extends RefineTest { * Test to fetch simple strings * @throws Exception */ - @BeforeMethod public void mockHttpCalls() throws Exception { mockStatic(ReconciledDataExtensionJob.class); @@ -236,9 +232,9 @@ public class ExtendDataOperationTests extends RefineTest { return fakeHttpCall(invocation.getArgument(0), invocation.getArgument(1)); } }; - PowerMockito.doAnswer(mockedResponse).when(ReconciledDataExtensionJob.class, "performQuery", anyString(), anyString()); + PowerMockito.doAnswer(mockedResponse).when(ReconciledDataExtensionJob.class, "postExtendQuery", anyString(), anyString()); } - + @AfterMethod public void cleanupHttpMocks() { mockedResponses.clear(); From eb42515b6ef07af806349ede924d1970e071cf5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Dec 2020 07:57:55 +0000 Subject: [PATCH 05/20] Bump httpclient5 from 5.0.2 to 5.0.3 Bumps [httpclient5](https://github.com/apache/httpcomponents-client) from 5.0.2 to 5.0.3. - [Release notes](https://github.com/apache/httpcomponents-client/releases) - [Changelog](https://github.com/apache/httpcomponents-client/blob/master/RELEASE_NOTES.txt) - [Commits](https://github.com/apache/httpcomponents-client/compare/rel/v5.0.2...rel/v5.0.3) Signed-off-by: dependabot[bot] --- main/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/pom.xml b/main/pom.xml index 1e22434d5..5a53b4c5d 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -293,7 +293,7 @@ org.apache.httpcomponents.client5 httpclient5 - 5.0.2 + 5.0.3 org.apache.httpcomponents From 710074d3822f28965b90c6f55265f60bcebb78bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Dec 2020 08:00:46 +0100 Subject: [PATCH 06/20] Bump sqlite-jdbc from 3.32.3.2 to 3.32.3.3 (#3395) Bumps [sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.32.3.2 to 3.32.3.3. - [Release notes](https://github.com/xerial/sqlite-jdbc/releases) - [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG) - [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.32.3.2...3.32.3.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/database/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/database/pom.xml b/extensions/database/pom.xml index 49c03e68e..64c99304c 100644 --- a/extensions/database/pom.xml +++ b/extensions/database/pom.xml @@ -153,7 +153,7 @@ org.xerial sqlite-jdbc - 3.32.3.2 + 3.32.3.3 com.fasterxml.jackson.core From 57d47b1492340814c44e199ffb16984ee924da1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Dec 2020 15:39:43 +0100 Subject: [PATCH 07/20] Bump sqlite-jdbc from 3.32.3.3 to 3.34.0 (#3400) Bumps [sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.32.3.3 to 3.34.0. - [Release notes](https://github.com/xerial/sqlite-jdbc/releases) - [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG) - [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.32.3.3...3.34.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/database/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/database/pom.xml b/extensions/database/pom.xml index 64c99304c..5cababbc5 100644 --- a/extensions/database/pom.xml +++ b/extensions/database/pom.xml @@ -153,7 +153,7 @@ org.xerial sqlite-jdbc - 3.32.3.3 + 3.34.0 com.fasterxml.jackson.core From 2cf6a359c235d9e9ec052e2ae44220a7a7e7d188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Dec 2020 07:53:06 +0100 Subject: [PATCH 08/20] Bump guava from 30.0-jre to 30.1-jre (#3409) Bumps [guava](https://github.com/google/guava) from 30.0-jre to 30.1-jre. - [Release notes](https://github.com/google/guava/releases) - [Commits](https://github.com/google/guava/commits) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- main/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/pom.xml b/main/pom.xml index 5a53b4c5d..e1f49c0fa 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -348,7 +348,7 @@ com.google.guava guava - 30.0-jre + 30.1-jre javax.xml.bind From 4b6106a386d529a79eb7c23484f8ad9e78a86196 Mon Sep 17 00:00:00 2001 From: Florian Giroud <6267288+fgiroud@users.noreply.github.com> Date: Tue, 15 Dec 2020 20:34:15 +0100 Subject: [PATCH 09/20] Run UI tests in continuous integration (#3393) * Fixed flaky tests * Refactored ui_test commans-line, added documentation * Attempt to build a workflow with cypress * Fixed CI UX tests build * Changed cyprss actions for pull-request * Merged Cypress workflow into the regular PR target workflow * Refactored Github workflows to include Cypress Tests * Revert Ci build to pull_request_target --- .github/workflows/pull_request.yml | 45 ++++++++++++++ .github/workflows/snapshot_release.yml | 41 ++++++++++++ .../technical-reference/build-test-run.md | 2 +- .../technical-reference/functional-tests.md | 12 ++-- main/tests/cypress/Readme.md | 15 ++--- main/tests/cypress/cypress.json | 2 +- .../project/facets/facets.text.spec.js | 0 .../project/undo_redo/extract.spec.js | 2 +- .../project/undo_redo/undo_redo.spec.js | 37 +++++------ .../project_management/create_project.spec.js | 2 +- .../tests/cypress/cypress/support/commands.js | 8 ++- main/tests/cypress/cypress/support/index.js | 2 +- .../cypress/cypress/support/openrefine_api.js | 11 ++-- main/tests/cypress/package.json | 30 ++++----- main/tests/cypress/yarn.lock | 8 +-- refine | 62 ++++++++++++++----- 16 files changed, 199 insertions(+), 80 deletions(-) delete mode 100644 main/tests/cypress/cypress/integration/project/facets/facets.text.spec.js diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index aff51029b..6e6354717 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -72,3 +72,48 @@ jobs: run: | mvn prepare-package -DskipTests=true mvn jacoco:report coveralls:report -DrepoToken=${{ secrets.COVERALLS_TOKEN }} -DpullRequest=${{ github.event.number }} + cypress_tests: + strategy: + matrix: + browser: ['chrome'] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2.3.4 + + - name: Restore dependency cache + uses: actions/cache@v2.1.3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Set up Java 8 + uses: actions/setup-java@v1 + with: + java-version: 8 + + - name: Build OpenRefine + run: ./refine build + + - name: Restore Tests dependency cache + uses: actions/cache@v2.1.3 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn + + - name: Install test dependencies + run: | + cd ./main/tests/cypress + yarn install + + - name: Test with Cypress on ${{ matrix.browser }} + run: | + echo REFINE_MIN_MEMORY=1400M >> ./refine.ini + echo REFINE_MEMORY=4096M >> ./refine.ini + ./refine ui_test ${{ matrix.browser }} cn3r2t "${{ secrets.CYPRESS_RECORD_KEY }}" + diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 0d8da984f..08ed1736f 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -6,6 +6,47 @@ on: - master jobs: + cypress_tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2.3.4 + + - name: Restore dependency cache + uses: actions/cache@v2.1.3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Set up Java 8 + uses: actions/setup-java@v1 + with: + java-version: 8 + + - name: Build OpenRefine + run: ./refine build + + - name: Restore Tests dependency cache + uses: actions/cache@v2.1.3 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn + + - name: Install test dependencies + run: | + cd ./main/tests/cypress + yarn install + + - name: Test with Cypress on chrome + run: | + echo REFINE_MIN_MEMORY=1400M >> ./refine.ini + echo REFINE_MEMORY=4096M >> ./refine.ini + ./refine ui_test chrome cn3r2t "${{ secrets.CYPRESS_RECORD_KEY }}" + build: services: diff --git a/docs/docs/technical-reference/build-test-run.md b/docs/docs/technical-reference/build-test-run.md index e8dbeebb5..d535d1b36 100644 --- a/docs/docs/technical-reference/build-test-run.md +++ b/docs/docs/technical-reference/build-test-run.md @@ -64,7 +64,7 @@ If you want to run only the server side portion of the tests, use: If you are running the UI tests for the first time, [you must go through the installation process.](functional-tests) If you want to run only the client side portion of the tests, use: ```shell -yarn --cwd ./main/tests/cypress run cypress open +./refine ui_test chrome ``` ## Running diff --git a/docs/docs/technical-reference/functional-tests.md b/docs/docs/technical-reference/functional-tests.md index b7b581e79..c055bc0fb 100644 --- a/docs/docs/technical-reference/functional-tests.md +++ b/docs/docs/technical-reference/functional-tests.md @@ -21,12 +21,13 @@ cd ./main/tests/cypress yarn install ``` -Cypress always assumes that OpenRefine is up and running on the local machine, the tests themselves do not launch OpenRefine, nor restarts it. +Cypress tests can be started in two modes: -Once OpenRefine is running, Cypress tests can be started in two modes ### Development / Debugging mode +Dev mode assumes that OpenRefine is up and running on the local machine, the tests themselves do not launch OpenRefine, nor restarts it. + Run : ```shell @@ -34,12 +35,15 @@ yarn --cwd ./main/tests/cypress run cypress open ``` It will open the Cypress test runner, where you can choose, replay, visualize tests. -This is the recommended way to run tests when adding or fixing tests +This is the recommended way to run tests when adding or fixing tests. +The runners assumes ### Command-line mode +Command line mode will starts OpenRefine with a temporary folder for data + ```shell -yarn --cwd ./main/tests/cypress run cypress run +./refine ui_test chrome ``` It will run all tests in the command-line, without windows, displaying results in the standard output diff --git a/main/tests/cypress/Readme.md b/main/tests/cypress/Readme.md index 893215023..df8fb9a40 100644 --- a/main/tests/cypress/Readme.md +++ b/main/tests/cypress/Readme.md @@ -1,13 +1,6 @@ -# OpenRefine test suite +# OpenRefine UI test suite -## Install +Please refer to the official OpenRefine documentation -``` -cd ./main/tests/e2e -npm install -``` - -## Usage - -- Run OpenRefine on a separate terminal -- Open the Cypress test runner with `./node_modules/.bin/cypress open` +- [How to build tests and run](https://docs.openrefine.org/technical-reference/build-test-run/) +- [Functional tests](https://docs.openrefine.org/technical-reference/functional-tests) diff --git a/main/tests/cypress/cypress.json b/main/tests/cypress/cypress.json index 20fe218dc..cff524327 100644 --- a/main/tests/cypress/cypress.json +++ b/main/tests/cypress/cypress.json @@ -2,7 +2,7 @@ "integrationFolder": "./cypress/integration", "nodeVersion": "system", "retries": { - "runMode": 1, + "runMode": 2, "openMode": 1 }, "env":{ diff --git a/main/tests/cypress/cypress/integration/project/facets/facets.text.spec.js b/main/tests/cypress/cypress/integration/project/facets/facets.text.spec.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/main/tests/cypress/cypress/integration/project/undo_redo/extract.spec.js b/main/tests/cypress/cypress/integration/project/undo_redo/extract.spec.js index 5eba6f8ce..916360b6f 100644 --- a/main/tests/cypress/cypress/integration/project/undo_redo/extract.spec.js +++ b/main/tests/cypress/cypress/integration/project/undo_redo/extract.spec.js @@ -88,7 +88,7 @@ describe(__filename, function () { cy.get('.dialog-container').should('exist').should('be.visible'); cy.get('.dialog-container button[bind="closeButton"]').click(); - cy.get('.dialog-container').should('not.be.visible'); + cy.get('.dialog-container').should('not.exist'); }); it('Ensure action are recorded in the extract panel', function () { diff --git a/main/tests/cypress/cypress/integration/project/undo_redo/undo_redo.spec.js b/main/tests/cypress/cypress/integration/project/undo_redo/undo_redo.spec.js index 39010e7bb..75b3dbf03 100644 --- a/main/tests/cypress/cypress/integration/project/undo_redo/undo_redo.spec.js +++ b/main/tests/cypress/cypress/integration/project/undo_redo/undo_redo.spec.js @@ -3,8 +3,8 @@ describe(__filename, function () { cy.loadAndVisitProject('food.mini.csv'); cy.deleteColumn('NDB_No'); - cy.get('#notification-container').should('be.visible').contains('Remove column NDB_No'); - cy.get('#notification-container .notification-action').should('be.visible').contains('Undo'); + cy.get('#notification-container').should('be.visible').should('to.contain', 'Remove column NDB_No'); + cy.get('#notification-container .notification-action').should('be.visible').should('to.contain', 'Undo'); }); it('Ensure the Undo button is effectively working', function () { @@ -12,7 +12,8 @@ describe(__filename, function () { cy.deleteColumn('NDB_No'); // ensure that the column is back in the grid - cy.get('#notification-container .notification-action').should('be.visible').contains('Undo').click(); + cy.get('#notification-container .notification-action').should('be.visible').should('to.contain', 'Undo'); + cy.get('#notification-container a[bind="undoLink"]').click(); cy.get('.data-table th[title="NDB_No"]').should('exist'); }); @@ -21,39 +22,39 @@ describe(__filename, function () { // delete NDB_No cy.deleteColumn('NDB_No'); - cy.get('#or-proj-undoRedo').contains('1 / 1'); - cy.get('.history-panel-body .history-now').contains('Remove column NDB_No'); + cy.get('#or-proj-undoRedo').should('to.contain', '1 / 1'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column NDB_No'); // delete Water cy.deleteColumn('Water'); - cy.get('#or-proj-undoRedo').contains('2 / 2'); - cy.get('.history-panel-body .history-now').contains('Remove column Water'); + cy.get('#or-proj-undoRedo').should('to.contain', '2 / 2'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column Water'); // Delete Shrt_Desc cy.deleteColumn('Shrt_Desc'); - cy.get('#or-proj-undoRedo').contains('3 / 3'); - cy.get('.history-panel-body .history-now').contains('Remove column Shrt_Desc'); + cy.get('#or-proj-undoRedo').should('to.contain', '3 / 3'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column Shrt_Desc'); // Open the Undo/Redo panel cy.get('#or-proj-undoRedo').click(); // ensure all previous actions have been recorded - cy.get('.history-panel-body .history-past a.history-entry:nth-of-type(2)').contains('Remove column NDB_No'); - cy.get('.history-panel-body .history-past a.history-entry:nth-of-type(3)').contains('Remove column Water'); - cy.get('.history-panel-body .history-now').contains('Remove column Shrt_Desc'); + cy.get('.history-panel-body .history-past').should('to.contain', 'Remove column NDB_No'); + cy.get('.history-panel-body .history-past').should('to.contain', 'Remove column Water'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column Shrt_Desc'); // successively undo all modifications cy.get('.history-panel-body .history-past a.history-entry:last-of-type').click(); cy.waitForOrOperation(); - cy.get('.history-panel-body .history-past').contains('Remove column NDB_No'); - cy.get('.history-panel-body .history-now').contains('Remove column Water'); - cy.get('.history-panel-body .history-future').contains('Remove column Shrt_Desc'); + cy.get('.history-panel-body .history-past').should('to.contain', 'Remove column NDB_No'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column Water'); + cy.get('.history-panel-body .history-future').should('to.contain', 'Remove column Shrt_Desc'); cy.get('.history-panel-body .history-past a.history-entry:last-of-type').click(); cy.waitForOrOperation(); - cy.get('.history-panel-body .history-now').contains('Remove column NDB_No'); - cy.get('.history-panel-body .history-future').contains('Remove column Water'); - cy.get('.history-panel-body .history-future').contains('Remove column Shrt_Desc'); + cy.get('.history-panel-body .history-now').should('to.contain', 'Remove column NDB_No'); + cy.get('.history-panel-body .history-future').should('to.contain', 'Remove column Water'); + cy.get('.history-panel-body .history-future').should('to.contain', 'Remove column Shrt_Desc'); }); // Very long test to run diff --git a/main/tests/cypress/cypress/integration/project_management/create_project.spec.js b/main/tests/cypress/cypress/integration/project_management/create_project.spec.js index c4eee4068..a77ea64fa 100644 --- a/main/tests/cypress/cypress/integration/project_management/create_project.spec.js +++ b/main/tests/cypress/cypress/integration/project_management/create_project.spec.js @@ -67,7 +67,7 @@ describe(__filename, function () { // cypress does not support window.location = ... cy.get('h2').contains('HTTP ERROR 404'); cy.location().should((location) => { - expect(location.href).contains('http://localhost:3333/__/project?'); + expect(location.href).contains(Cypress.env('OPENREFINE_URL')+'/__/project?'); }); cy.location().then((location) => { diff --git a/main/tests/cypress/cypress/support/commands.js b/main/tests/cypress/cypress/support/commands.js index 48197ea83..d7b5131ae 100644 --- a/main/tests/cypress/cypress/support/commands.js +++ b/main/tests/cypress/cypress/support/commands.js @@ -41,7 +41,7 @@ Cypress.Commands.add('doCreateProjectThroughUserInterface', () => { // cypress does not support window.location = ... cy.get('h2').contains('HTTP ERROR 404'); cy.location().should((location) => { - expect(location.href).contains('http://localhost:3333/__/project?'); + expect(location.href).contains(Cypress.env('OPENREFINE_URL')+'/__/project?'); }); cy.location().then((location) => { @@ -67,7 +67,9 @@ Cypress.Commands.add('assertCellEquals', (rowIndex, columnName, value) => { cy.get(`table.data-table thead th[title="${columnName}"]`).then(($elem) => { // there are 3 td at the beginning of each row const columnIndex = $elem.index() + 3; - cy.get(`table.data-table tbody tr:nth-child(${cssRowIndex}) td:nth-child(${columnIndex}) div`).contains(value, { timeout: 5000 }); + cy.get(`table.data-table tbody tr:nth-child(${cssRowIndex}) td:nth-child(${columnIndex}) div.data-table-cell-content > span`).should(($cellSpan)=>{ + expect($cellSpan.text()).equals(value); + }); }); }); @@ -92,7 +94,7 @@ Cypress.Commands.add('waitForDialogPanel', () => { Cypress.Commands.add('confirmDialogPanel', () => { cy.get('body > .dialog-container > .dialog-frame .dialog-footer button[bind="okButton"]').click(); - cy.get('body > .dialog-container > .dialog-frame').should('not.be.visible'); + cy.get('body > .dialog-container > .dialog-frame').should('not.exist'); }); Cypress.Commands.add('columnActionClick', (columnName, actions) => { diff --git a/main/tests/cypress/cypress/support/index.js b/main/tests/cypress/cypress/support/index.js index cc150ee9d..5ad91fddf 100644 --- a/main/tests/cypress/cypress/support/index.js +++ b/main/tests/cypress/cypress/support/index.js @@ -37,7 +37,7 @@ afterEach(() => { }); before(() => { - cy.request('http://127.0.0.1:3333/command/core/get-csrf-token').then((response) => { + cy.request(Cypress.env('OPENREFINE_URL')+'/command/core/get-csrf-token').then((response) => { // store one unique token for block of runs token = response.body.token; }); diff --git a/main/tests/cypress/cypress/support/openrefine_api.js b/main/tests/cypress/cypress/support/openrefine_api.js index 540f267b8..eb01c1779 100644 --- a/main/tests/cypress/cypress/support/openrefine_api.js +++ b/main/tests/cypress/cypress/support/openrefine_api.js @@ -1,8 +1,9 @@ Cypress.Commands.add('setPreference', (preferenceName, preferenceValue) => { - cy.request(Cypress.env('OPENREFINE_URL') + '/command/core/get-csrf-token').then((response) => { + const openRefineUrl = Cypress.env('OPENREFINE_URL') + cy.request( openRefineUrl + '/command/core/get-csrf-token').then((response) => { cy.request({ method: 'POST', - url: `http://127.0.0.1:3333/command/core/set-preference`, + url: `${openRefineUrl}/command/core/set-preference`, body: `name=${preferenceName}&value="${preferenceValue}"&csrf_token=${response.body.token}`, form: false, headers: { @@ -15,12 +16,13 @@ Cypress.Commands.add('setPreference', (preferenceName, preferenceValue) => { }); Cypress.Commands.add('cleanupProjects', () => { + const openRefineUrl = Cypress.env('OPENREFINE_URL') cy.get('@deletetoken', { log: false }).then((token) => { cy.get('@loadedProjectIds', { log: false }).then((loadedProjectIds) => { for (const projectId of loadedProjectIds) { cy.request({ method: 'POST', - url: `http://127.0.0.1:3333/command/core/delete-project?csrf_token=` + token, + url: `${openRefineUrl}/command/core/delete-project?csrf_token=` + token, body: { project: projectId }, form: true, }).then((resp) => { @@ -32,6 +34,7 @@ Cypress.Commands.add('cleanupProjects', () => { }); Cypress.Commands.add('loadProject', (fixture, projectName) => { + const openRefineUrl = Cypress.env('OPENREFINE_URL'); const openRefineProjectName = projectName ? projectName : fixture; cy.fixture(fixture).then((content) => { cy.get('@token', { log: false }).then((token) => { @@ -54,7 +57,7 @@ Cypress.Commands.add('loadProject', (fixture, projectName) => { cy.request({ method: 'POST', - url: `http://127.0.0.1:3333/command/core/create-project-from-upload?csrf_token=` + token, + url: `${openRefineUrl}/command/core/create-project-from-upload?csrf_token=` + token, body: postData, headers: { 'content-type': 'multipart/form-data; boundary=----BOUNDARY', diff --git a/main/tests/cypress/package.json b/main/tests/cypress/package.json index d6168940e..2b4d41702 100644 --- a/main/tests/cypress/package.json +++ b/main/tests/cypress/package.json @@ -1,16 +1,16 @@ { - "name":"OpenRefine-Cypress-Test-Suite", - "version":"1.0.0", - "description":"Cypress tests for OpenRefine", - "license":"BSD-3-Clause", - "author":"OpenRefine", - "private":true, - "dependencies":{ - "cypress":"5.6.0", - "cypress-file-upload":"^4.1.1", - "cypress-wait-until":"^1.7.1", - "dotenv":"^8.2.0", - "fs-extra":"^9.0.1", - "uniqid":"^5.2.0" - } -} \ No newline at end of file + "name": "OpenRefine-Cypress-Test-Suite", + "version": "1.0.0", + "description": "Cypress tests for OpenRefine", + "license": "BSD-3-Clause", + "author": "OpenRefine", + "private": true, + "dependencies": { + "cypress": "6.0.1", + "cypress-file-upload": "^4.1.1", + "cypress-wait-until": "^1.7.1", + "dotenv": "^8.2.0", + "fs-extra": "^9.0.1", + "uniqid": "^5.2.0" + } +} diff --git a/main/tests/cypress/yarn.lock b/main/tests/cypress/yarn.lock index 1a2dd1121..6085e7df2 100644 --- a/main/tests/cypress/yarn.lock +++ b/main/tests/cypress/yarn.lock @@ -377,10 +377,10 @@ cypress-wait-until@^1.7.1: resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-1.7.1.tgz#3789cd18affdbb848e3cfc1f918353c7ba1de6f8" integrity sha512-8DL5IsBTbAxBjfYgCzdbohPq/bY+IKc63fxtso1C8RWhLnQkZbVESyaclNr76jyxfId6uyzX8+Xnt0ZwaXNtkA== -cypress@5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-5.6.0.tgz#6781755c3ddfd644ce3179fcd7389176c0c82280" - integrity sha512-cs5vG3E2JLldAc16+5yQxaVRLLqMVya5RlrfPWkC72S5xrlHFdw7ovxPb61s4wYweROKTyH01WQc2PFzwwVvyQ== +cypress@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-6.0.0.tgz#57050773c61e8fe1e5c9871cc034c616fcacded9" + integrity sha512-A/w9S15xGxX5UVeAQZacKBqaA0Uqlae9e5WMrehehAdFiLOZj08IgSVZOV8YqA9OH9Z0iBOnmsEkK3NNj43VrA== dependencies: "@cypress/listr-verbose-renderer" "^0.4.1" "@cypress/request" "^2.88.5" diff --git a/refine b/refine index 5cc80ba12..cd669f4a6 100755 --- a/refine +++ b/refine @@ -64,7 +64,7 @@ and is one of test ................................ Run all OpenRefine tests server_test ......................... Run only the server tests - ui_test ............................. Run only the UI tests + ui_test ........ Run only the UI tests (If passing a project Id and a Record Key, tests will be recorded in Cypress.io Dashboard) extensions_test ..................... Run only the extensions tests broker .............................. Run OpenRefine Broker @@ -480,15 +480,31 @@ test() { } ui_test() { - INTERACTIVE=$1 + get_revision + + BROWSER="$1" + CYPRESS_PROJECT_ID="$2" + CYPRESS_RECORD_KEY="$3" + CYPRESS_RECORD=0 + + if [ -z "$BROWSER" ] ; then + BROWSER="electron" + fi - windmill_prepare + if [ ! -z "$CYPRESS_PROJECT_ID" ] && [ ! -z "$CYPRESS_RECORD_KEY" ] ; then + CYPRESS_RECORD=1 + echo "Tests will be recorded in Cypress Dashboard" + elif [ ! -z "$CYPRESS_PROJECT_ID" ] && [ -z "$CYPRESS_RECORD_KEY" ] ; then + fail "Found a Cypress project id but no record key" + fi REFINE_DATA_DIR="${TMPDIR:=/tmp}/openrefine-tests" add_option "-Drefine.headless=true" - - run fork + add_option "-Drefine.autoreload=false" + add_option "-Dbutterfly.autoreload=false" + + run fork > /dev/null echo "Waiting for OpenRefine to load..." sleep 5 @@ -498,16 +514,26 @@ ui_test() { fi echo "... proceed with the tests." echo "" - - load_data "$REFINE_TEST_DIR/data/food.csv" "Food" - sleep 3 - echo "" - - echo "Starting Windmill..." - if [ -z "$INTERACTIVE" ] ; then - "$WINDMILL" firefox firebug loglevel=WARN http://${REFINE_HOST}:${REFINE_PORT}/ jsdir=$REFINE_TEST_DIR/client/src exit + + echo "Starting Cypress..." + CYPRESS_RUN_CMD="yarn --cwd ./main/tests/cypress run cypress run --browser $BROWSER --headless --quiet --reporter list --env OPENREFINE_URL=http://$REFINE_HOST:$REFINE_PORT" + if [ "$CYPRESS_RECORD" = "1" ] ; then + # if tests are recorded, project id is added to env vars, and --record flag is added to the cmd-line + export CYPRESS_PROJECT_ID=$CYPRESS_PROJECT_ID + CYPRESS_RUN_CMD="$CYPRESS_RUN_CMD --record --key $CYPRESS_RECORD_KEY --tag $BROWSER,$REVISION" + fi + export MOZ_FORCE_DISABLE_E10S=1 + echo $CYPRESS_RUN_CMD + $CYPRESS_RUN_CMD + + if [ "$?" = "0" ] ; then + UI_TEST_SUCCESS="1" else - "$WINDMILL" firefox firebug loglevel=WARN http://${REFINE_HOST}:${REFINE_PORT}/ + UI_TEST_SUCCESS="0" + fi + + if [ "$CYPRESS_RECORD" = "1" ] ; then + echo "You can review tests on Cypress.io: https://dashboard.cypress.io/projects/$CYPRESS_PROJECT_ID/runs" fi echo "" @@ -515,6 +541,10 @@ ui_test() { /bin/kill -9 $REFINE_PID echo "Cleaning up" rm -rf "$REFINE_DATA_DIR" + + if [ "$UI_TEST_SUCCESS" = "0" ] ; then + error "The UI test suite failed." + fi } server_test() { @@ -926,8 +956,8 @@ case "$ACTION" in distclean) mvn distclean;; test) test $1;; tests) test $1;; - ui_test) ui_test $1;; - ui_tests) ui_test $1;; + ui_test) ui_test $1 $2 $3;; + ui_tests) ui_test $1 $2 $3;; server_test) server_test $1;; server_tests) server_test $1;; extensions_test) extensions_test $1;; From 2f0869060094d2961e23deac6190eb19473ea876 Mon Sep 17 00:00:00 2001 From: Antonin Delpeuch Date: Wed, 16 Dec 2020 09:36:54 +0100 Subject: [PATCH 10/20] Change Cypress project id after merge --- .github/workflows/pull_request.yml | 2 +- .github/workflows/snapshot_release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6e6354717..2465e386e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -115,5 +115,5 @@ jobs: run: | echo REFINE_MIN_MEMORY=1400M >> ./refine.ini echo REFINE_MEMORY=4096M >> ./refine.ini - ./refine ui_test ${{ matrix.browser }} cn3r2t "${{ secrets.CYPRESS_RECORD_KEY }}" + ./refine ui_test ${{ matrix.browser }} s5du3k "${{ secrets.CYPRESS_RECORD_KEY }}" diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 08ed1736f..8c81dd87e 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -45,7 +45,7 @@ jobs: run: | echo REFINE_MIN_MEMORY=1400M >> ./refine.ini echo REFINE_MEMORY=4096M >> ./refine.ini - ./refine ui_test chrome cn3r2t "${{ secrets.CYPRESS_RECORD_KEY }}" + ./refine ui_test chrome s5du3k "${{ secrets.CYPRESS_RECORD_KEY }}" build: From 32b4787c761ddeb7b85018b2f7fc6f772b2be191 Mon Sep 17 00:00:00 2001 From: Florian Giroud <6267288+fgiroud@users.noreply.github.com> Date: Wed, 16 Dec 2020 09:51:18 +0100 Subject: [PATCH 11/20] Added edge to the CI build, #3401 (#3411) --- .github/workflows/snapshot_release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 8c81dd87e..4f5100f1f 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -9,6 +9,10 @@ jobs: cypress_tests: runs-on: ubuntu-latest + strategy: + matrix: + browser: ['edge', 'chrome'] + steps: - uses: actions/checkout@v2.3.4 @@ -25,6 +29,16 @@ jobs: with: java-version: 8 + - name: Install Edge + if: matrix.browser == 'edge' + run: | + sudo curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg + sudo install -o root -g root -m 644 microsoft.gpg /etc/apt/trusted.gpg.d/ + sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list' + sudo rm microsoft.gpg + sudo apt-get update + sudo apt-get install microsoft-edge-dev + - name: Build OpenRefine run: ./refine build From 7ce2013abee47e9e5f3b0fee0fc10160e12be3c3 Mon Sep 17 00:00:00 2001 From: Antonin Delpeuch Date: Wed, 16 Dec 2020 09:55:11 +0100 Subject: [PATCH 12/20] Fix test run description to include browser --- .github/workflows/snapshot_release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 4f5100f1f..c555b53fa 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -55,11 +55,11 @@ jobs: cd ./main/tests/cypress yarn install - - name: Test with Cypress on chrome + - name: Test with Cypress on ${{ matrix.browser }} run: | echo REFINE_MIN_MEMORY=1400M >> ./refine.ini echo REFINE_MEMORY=4096M >> ./refine.ini - ./refine ui_test chrome s5du3k "${{ secrets.CYPRESS_RECORD_KEY }}" + ./refine ui_test ${{ matrix.browser }} s5du3k "${{ secrets.CYPRESS_RECORD_KEY }}" build: From 8eabec1ea58bf199ee145dee10e21017bd04657f Mon Sep 17 00:00:00 2001 From: allanaaa Date: Sat, 19 Dec 2020 09:27:57 -0500 Subject: [PATCH 13/20] Docs > Exporting (#3336) * Exporting text, added link to Starting * Expanded on custom tabular exporter * Update exporting.md * img problems? * retry * added SQL section + exporting operations * Delete templating-exporter.PNG * Create templating-exporter.png * Expansion + some temporary text * Update exporting.md * Formatting and etc * SQL exporter --- docs/docs/manual/exporting.md | 115 ++++++++++++++++--- docs/docs/manual/starting.md | 2 +- docs/static/img/custom-tabular-exporter.png | Bin 0 -> 31746 bytes docs/static/img/custom-tabular-exporter2.png | Bin 0 -> 14785 bytes docs/static/img/sql-exporter.png | Bin 0 -> 30379 bytes docs/static/img/sql-exporter2.png | Bin 0 -> 11149 bytes docs/static/img/templating-exporter.png | Bin 0 -> 43498 bytes 7 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 docs/static/img/custom-tabular-exporter.png create mode 100644 docs/static/img/custom-tabular-exporter2.png create mode 100644 docs/static/img/sql-exporter.png create mode 100644 docs/static/img/sql-exporter2.png create mode 100644 docs/static/img/templating-exporter.png diff --git a/docs/docs/manual/exporting.md b/docs/docs/manual/exporting.md index 983be9446..bf0f12237 100644 --- a/docs/docs/manual/exporting.md +++ b/docs/docs/manual/exporting.md @@ -6,34 +6,117 @@ sidebar_label: Exporting ## Overview +Once your data is cleaned, you will need to get it out of OpenRefine and into the system of your choice. OpenRefine outputs a number of file formats, can upload your data directly into Google Sheets, and can create or update statements on Wikidata. + +You can also [export your full project data](#export-a-project) so that it can be opened by someone else using OpenRefine (or yourself, on another computer). ## Export data -Note you will only export data in the current view - that is, with current filters and facets applied. +Many of the following options only export data in the current view - that is, with current filters and facets applied. Some will give you the choice to export your entire dataset or just your current view. +To export from a project, click the Export dropdown button at the top right corner and pick the format you want. You options are: - -* TSV/CSV -* HTML table +* Tab-separated value (TSV) or Comma-separated value (CSV) +* HTML-formatted table * Excel (XLS or XLSX) * ODF spreadsheet -* Google Sheets \ +* Upload to Google Sheets (requires [Google account authorization](starting#google-sheet-from-drive)) +* [Custom tabular exporter](#custom-tabular-exporter) +* [SQL statement exporter](#sql-statement-exporter) +* [Templating exporter](#templating-exporter) -* Custom tabular export -* SQL -* Templating export \ +You can also export reconciled data to Wikidata, or export your Wikidata schema for future use with other OpenRefine projects: -* Upload edits to Wikidata -* Export to QuickStatement -* Export Wikidata schema +* [Upload edits to Wikidata](wikidata#upload-edits-to-wikidata) +* [Export to QuickStatements](wikidata#quickstatements-export) (version 1) +* [Export Wikidata schema](wikidata#import-and-export-schema) +### Custom tabular exporter + +![A screenshot of the custom tabular content tab.](/img/custom-tabular-exporter.png) + +With the custom tabular exporter, you can choose which of your data to export, the separator you wish to use, and whether you'd like to download it to your computer or upload it into a Google Sheet. + +On the Content tab, you can drag and drop the columns appearing in the column list to reorder the output. The options for reconciled and date data are applied to each column individually. + +This exporter is especially useful with reconciled data, as you can choose whether you wish to output the cells' original values, the matched values, or the matched IDs. Ouputting “match entity's name”, “matched entity's ID”, or “cell's content” will output, respectively, the contents of `cell.recon.match.name`, `cell.recon.match.id`, and `cell.value`. + +“Output nothing for unmatched cells” will export empty cells for both newly-created matches and cells with no chosen matches. “Link to matched entity's page” will produce hyperlinked text in an HTML table output, but have no effect in other formats. + +At this time, the date-formatting options in this window do not work. You can [keep track of this issue on Github](https://github.com/OpenRefine/OpenRefine/issues/3368). +In the future, you will also be able to choose how to [output date-formatted cells](exploring#dates). You can create a custom date output by using [formatting according to the SimpleDateFormat parsing key found here](grelfunctions#todateo-b-monthfirst-s-format1-s-format2-). + +![A screenshot of the custom tabular file download tab.](/img/custom-tabular-exporter2.png) + +On the Download tab, you can generate a preview of how the first ten rows of your dataset will output. If you do not choose one of the file formats on the right, the Download button will generate a text file. On the Upload tab, you can create a new Google Sheet. + +With the Option Code tab, you can copy JSON of your current settings to reuse on another project, or you can paste in existing JSON settings to apply to the current project. + +### SQL exporter + +The SQL exporter creates a SQL statement containing the data you’ve exported, which you can use to overwrite or add to an existing database. Choosing ExportSQL exporter will bring up a window with two tabs: one to define what data to output, and another to modify other aspects of the SQL statement with options to preview and download the statement. + +![A screenshot of the SQL statement content window.](/img/sql-exporter.png) + +The Content tab allows you to craft your dataset into an SQL table. From here, you can choose which columns to export, the data type to export for each (or choose "VARCHAR"), and the maximum character length for each field (if applicable based on the data type). You can set a default value for empty cells after unchecking “Allow null” in one or more columns. + +With this output tool, you can choose whether to output only currently visible rows, or all the rows in your dataset, as well as whether to include empty rows. Trimming column names will remove their whitespace characters. + +![A screenshot of the SQL statement download window.](/img/sql-exporter2.png) + +The Download tab allows you to finalize your complete SQL statement. + +Include schema means that you will start your statement with the creation of a table. Without that, you will only have an INSERT statement. + +Include content means the INSERT statement with data from your project. Without that, you will only create empty columns. + +You can include DROP and IF EXISTS if you require them, and set a name for the table which the statement will refer to. + +You can then preview your statement, which will open up a new browser tab/window showing a statement with the first ten rows of your data (if included), or you can save a `.sql` file to your computer. + +### Templating exporter + +If you pick Templating… from the Export dropdown menu, you can “roll your own” exporter. This is useful for formats that we don't support natively yet, or won't support. The Templating exporter generates JSON by default. + +The window that appears allows you to set your own separators, prefix, and suffix to create a complete dataset in the language of your choice. In the Row Template section, you can choose which columns to generate from each row by calling them with variables. + +This can be used to: +* output reconciliation data (`cells["column name"].recon.match.name`, `.recon.match.id`, and `.recon.best.name`, for example) instead of cell values +* create multiple columns of output from different member fields of a single project column +* employ GREL expressions to modify cell data for output (for example, `cells["column name"].value.toUppercase()`). + +Anything that appears inside doubled curly braces ({{}}) is treated as a GREL expression; anything outside is generated as straight text. You can use Jython or Clojure by declaring it at the start: for example, `{{jython:return cells["Author"].value}}` will run a Jython expression. + +:::caution +Note that some syntax is different in this tool than elsewhere in OpenRefine: a forward slash must be escaped with a backslash, while other characters do not need escaping. You cannot, at this time, include a closing curly brace (}) anywhere in your expression, or it will cause it to malfunction. +::: + +You can include [regular expressions](expressions#regular-expressions) as usual (inside forward slashes, with any GREL function that accepts them). For example, you could output a version of your cells with punctuation removed, using an expression such as `{{jsonize(cells["Column Name"].value.replaceChars("/[.!?$&,/]/",""))}}`. + +You could also simply output a plain-text document inserting data from your project into sentences (for example, "In `{{cells["Year"].value}}` we received `{{cells["RequestCount"].value}}` requests."). + +You can use the shorthand `${Column Name}` (no need for quotes) to insert column values directly. You cannot use this inside an expression, because of the closing curly brace. + +If your projects is in records mode, the Row separator field will insert a separator between records, rather than individual rows. Rows inside a single record will be directly appended to one another as per the content in the Row Template field. + +![A screenshot of the Templating exporter generating JSON by default.](/img/templating-exporter.png) + +Once you have created your template, you may wish to save the text you produced in each field, in order to reuse it in the future. Once you click Export OpenRefine will output a simple text file, and your template will be discarded. + +We have recipes on using the Templating exporter to [produce several different formats](https://github.com/OpenRefine/OpenRefine/wiki/Recipes#12-templating-exporter). ## Export a project +You can share a project in progress with another computer, a colleague, or with someone who wants to check your history. This can be useful for showing that your data cleanup didn’t distort or manipulate the information in any way. Once you have exported a project, another OpenRefine installation can [import it as a new project](starting#import-a-project). +:::caution +OpenRefine project archives contain confidential data from previous steps which is still accessible to anyone who has the file. If you are hoping to keep your original dataset hidden for privacy reasons, such as using OpenRefine to anonymize information, do not share your project archive. +::: -* tar.gz only -* Optional rename -* Local or to Google Drive - * Doesn’t supply a Google Drive link, just gives a confirmation message - * Other user (or you on another computer) will need to download it and save it locally in order to import it \ No newline at end of file +From the Export dropdown, select OpenRefine project archive to file. OpenRefine exports your full project with all of its history. It does not export any current views or applied facets. Any reconciliation information will be preserved, but the importing installation will need to add the same reconciliation services to keep working with that data. + +OpenRefine exports files in `.tar.gz` format. You can rename the file when you save it; otherwise it will bear the project name. You can either save it locally or upload it to Google Drive (which requires you to authorize a Google account), using the OpenRefine project archive to Google Drive... option. OpenRefine will not share the link with you, only confirm that the file was uploaded. + +## Export operations + +You can [save and re-apply the history of any project](running#reusing-operations) (all the operations shown in the Undo/Redo tab). This creates JSON that you can save for later reuse on another OpenRefine project. \ No newline at end of file diff --git a/docs/docs/manual/starting.md b/docs/docs/manual/starting.md index 5e689fd1b..46208dbd4 100644 --- a/docs/docs/manual/starting.md +++ b/docs/docs/manual/starting.md @@ -137,7 +137,7 @@ You should create a project name at this stage. You can also supply tags to keep Because OpenRefine only runs locally on your computer, you can’t have a project accessible to more than one person at the same time. -The best way to collaborate with another person is to export and import projects that save all your changes, so that you can pick up where someone else left off. You can also [export projects](exporting) and import them to new computers of your own, such as for working on the same project from the office and from home. +The best way to collaborate with another person is to export and import projects that save all your changes, so that you can pick up where someone else left off. You can also [export projects](exporting#export-a-project) and import them to new computers of your own, such as for working on the same project from the office and from home. An exported project will include all of the [history](running#history-undoredo), so you can see (and undo) all the changes from the previous user. It is essentially a point-in-time snapshot of their work. OpenRefine only exports projects as `.tar.gz` files at this time. diff --git a/docs/static/img/custom-tabular-exporter.png b/docs/static/img/custom-tabular-exporter.png new file mode 100644 index 0000000000000000000000000000000000000000..89099eb46cfd0c2f674b1adc9d2d9fcb5b298f7b GIT binary patch literal 31746 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/sql-exporter2.png b/docs/static/img/sql-exporter2.png new file mode 100644 index 0000000000000000000000000000000000000000..1f23784e831d532af6850379b736e063fe677002 GIT binary patch literal 11149 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 G>E8ek3jhoN literal 0 HcmV?d00001 diff --git a/docs/static/img/templating-exporter.png b/docs/static/img/templating-exporter.png new file mode 100644 index 0000000000000000000000000000000000000000..836e47d6873edbfc863bdb9e4d0fc2c8e5af3d32 GIT binary patch literal 43498 zcmcG$2UwF^ur7>ctJv6z3P{sU6H$;NNR1t7Mny$xq)Q110YgpPcBLsOMTA%oL`tNF znxK?Oml_~}D1k%>A%+l0&rQ%>&N=t_&;6fsFVCZVU-FeTvu3?B^Uf?Do;z#3W#jIR zQc_Y|Y|fm#ASJa1E+w`4&4zX2Pf9GRL&d*VK`vMymn!X1nh~F@^*`otOiHROWfR}? zH}Sdj^)t>8DXFc^l7FkPq3?X8qzd6SCy!l>@}LRKkq3ZKgmi9-0qCUslhwv;kK0~q zT)o*wP1IKh85`|8LB6l!vvvi+~;}l03Jmsq`@=6P@QhD-fSpL(194?S-JEtVHg;`EJXR|@V{HcK$y!w zG;EqOOvCb7+HKOm#{GqJSXk9d6d*KDECAvK@j-&y5qX??RCSer@t2s37G_`gf@UOU zIsjI{>L|RscFwPxdMUQ?ID#A> zf%hTSivA!a69^MEu)+eLLv~Jw=$p(x{XeGe4YMNG5_$NYoqeT8J&c2cFm%gZ@4Xxka%d@^i( zH!b^_BGNa$+%2oE>z(FwFRx8Li_wdk4nq92l^oFeObmCtwA?i4K*DKnW7XP^eY6i9 z&)?ELz=z?^qjne)It)YDT!I>S-l=DkrK=19qa%2?)6rbo7FqgRlKUe}+BoQ!u_;8x zGEEfnpA}Nwjk8lyZM3s6Q}Zu}9l8((PRtuOlndt;sL%07u6C49hbcKlw!u7wKXn(Q zp&Tf*C7&MjT`cz1VD{B9O^~3mDr4v6LvR{pHn(>iovHUO+{~2Q z(J_$UX6!$#+}LvNlz>2dfhQWlcRt%pURd6qP%B6;zJh!z->AYgDI5g+(g-Oskt17T z?}COI@6PT<7^;rO3d0Qz=o#{rT?F=Y-wS85pR;h;BpcwK+w$^*`|IMI-#GDUn1C^WkIi}gQkNOBt((|*>98uL-)r&!BpS0+K<;(h{(?p87e&y)Lx~syK1E~qAF4dKzCdHeaH7iF^YYvIATJjSrMV47P_`0QuziQ>E zVb%ZaVOJ-SA{HiDYy*~eVeD`BXJ&_FBrmyoTsL3*g5|Gzq6IzZ3<^d?<)?B#O5SQO z8S7V%EB8W9E*rv$NRTQK9Ut#HL!xry$NV1mc3x})aa7U54pb+OQmG`l_MbbT#_)T` zcn!fuHxZx=<+g3&Z#PK9yRLWOgvo1cHU>!t;Zj0D8GZO0tqbB2%&(UWXa275_tO!& zOf7tz8(Mds9?Gl^zpn>772!heeAG!m!$Y-sJXZ5TyZH@g@v!R&ze`Dl93AD~rOM&N z>O8Ws^Gniqbr7%1VLV*!5Ju;F(l~bLAPatAy0Bajx~d>&O5_%`O6u$79o>j}beLe3 z)J?Ur&F+IKuthaU^m~ZqkJivPqO)7P7tAq_IFL=Z0ve8ZD|G)MCDpVt6HSdDHs_p4 z1U;>?nq3Rx*?+;aF`N9#0)3RpEZG>YR;g^Ej>h3hp$>Eb<_1z4nRt(MsBRj`854u zH{!7st>Q;))09Z)sh2gHy+q+5#JXHDnCp6#Svpw_*lh6)H|5q!EN%IdL4?{T$sEqT zye7;K&Alom7J8pp^M6RJu4!wBMAu)h{PV$IgZ}G+|NXh}hm7dD7`w%zHgQjnu?% zorA2|`IT`g$k!fGfydiT$Q)5P$Z)27Q>T$MxKcmvUCA$XT3H6S4Ko3u?;2`5=)v(G zQz1AXG`Sumhr%5voJMA6Xob*tEVTo?8dz&!xj-)iX;26Oyq1hNtqUH~hA#arE`|6J?Ox&M_5po0U%IJh&JJ^XQGCdpXf*5RbzyBdPbFB2cj# zbjA8pLRI4+Fw48J=A%vgFwUusOg7{{&v1Pr#oAxx5i;v z5aYc0g~27NDh^tSlvvf*Z4LJ%YIkau$R}8jK5_!8p9I7c;yrGCTYxS%6hcnVz3$GD zuXv}tj+QQg=zA-RcSXiS@JECEyQ1~Y%QK=-x?$S5sl_(&{QO%^6XXBh1XWf^tY^hC zSf9v1xQ5}rWxoaUhGFitA9)RgxoMe%ZR3+c2+5F6N z_;=0IxIv{-Qs*X1x)J=uW2K4kZSUyo*H>HgN}s2@|I4!_2Oh-dTW>4O3L$H*@}hj%74c4z}x??4h36FMy-+E@9;7EUc)M9dk#qPGi@vN=^M4t?JBcb~7#I#CkKn zGuDsX|Jz!d_zu!#z~m$!^W7UzRNU)ne?&^pheToqgS^O?rB zp0!9wAQ0_fEqXr3aex&Gt2mqVvxFEq_w>qdA3nXU>BO-N4-T+Q4B>%W)v4q#hcQ{o zIf8B`rZUk878YH8py$up+{^q`JxPBLAS#rpMOM>nN!{m4*3pIF{pd}R^5o-y==w+s zP>Y@r{S@G6IP-X|2hn-KCO-8?Jq8K6OA}U&dJA1xo!q$5KB(|kfXmZXh!&&DxszJq zrfPV*fcpo{CMnDFWJb9UBmm-a?~x}2t*kGUN|Bh_ihq%BKyiZhwAqelwJ)&Nbb+Ep z)uun>Bduimu1vJOU?ujd4Cm_!%@5jftFC3qgoQ+IhnFBcYJ5on0_LTool0!!IlBB#VqTx z=D5QfFaZhur%lSsgr)3__zoa_WAC($a+dQz{2=QyJD(Z!@Yp`0fVP=j9=i85IuzgL zUy9u?_x0o71}w(hrZiF)akw7ib+!nqwMs>T$3Ib1yp}AqxF&@pXgm%1Wc7=GJk&pH z?mr>(Pw@O_`TqT&Um&>R-PipM*+0F+*OG2f+;X;sW>}0Kfj`2VvJ9&ork@N#ch#S~ zfXx>K(J4&wBjU%-D;V#vezx~{uu43?an;{C z{n|+9-_3Z(y92hJA~$@|fapI-n*!*H8WJ0S=P~;k`viHV`uk~^O-bBK7=DrTe6YcL z29#DG+R!%h98iDV`+M6IGbXxSt`SM1aAQYa0=Vqh_v4lsk z@0U~Rx1`xl7ca|QRL~&};)G_5tzUMf^}WM150at&UW)lJ_z}o1ys-u@+B}GHo~r4~ zSSIU@pKX0t&1mR^u5F-euItF?n`k?r?yZbRg2BtDIKiK@? zua$m^DW|QT{wdcSUR0l$decuj9K@r}>BDK*ZU@u3@cr zUE*rs3#4N`l>$2}Y6@w2@XiKlfqrM{s{yQ!NE)7guMuy zs^kit-^jCTd>fu}Vslm)*4N}hDc1#ITZ5D7wb^paD_1=v4Ab&>v87qmW8{1$@qN$vKr%)LW=_oz2ymw>jvm+h z+q$KgP9HTeoSY{{Pj*1kN-L_vTBbs)N<&49CU(PILCX!od%{Jt(HraZTvg#}83!_c zbKKl`KC?}o+~3W9I*CXu(bE~#l}~MB8!gK%2;+~a83e08Vp{KOMV_hyDWz3nLPm)} z%ScO;x4D&ZmclB7IrcJ3?{P+Bsd>tYFBgPD?%nIqq4$b0|IB74;HLSv&ECwjat%AY zjw&!|{{d%cpXaS+h*F;t)S$aqJLwU6~MLD7AX+yihAE@*_%NLxb`Ek%Djzv%12B(**G_H?8B zIJxuNTZeY4ntj^5Ly%`;?9%$EJ^z|96OBcyTAUvkEL6{0!ULhVUMqZ<|FV`TH@gAq zw9)T_k1*dXqE>q}M=|3w$g$Z}VOM=(rF((_WZ*m;1~qzkDY5r3xcNa}BF&B~i#Jr< zHMCx!(ikYpsOC*WKm*3rWNR6N`w_LbYw8@Bl(82{8H15F-e1R%-l_cr)vslLX*kNR zzdoM8893vmA{%3~7UiNDsG~DI;h1gry-?r&;Vy=pKBf zctjW4lDa@T6EJ{QXdufiqEf7sLtbB(Hb~;=(Dmd{8T~NO(=54+1E?-yNAUOfwW#92 zM_sH^aCg1Yx1LL5LQ)q(Cw6F?^haNP`6Tw@<0?Hod>r+%5`TDA15jS&uS&MJD`^eA4e5qO=ysDf-}!Sbam98uP(W^-G#a)TJc$J7&*upRjGm3 z)Lixm7`0>-2xOjjo}FFlOpWQdS8|hxGX~a)oO$mBC|*V@`FAhN8@!-9rg>wTyT^j( zUUaol1YO26Bk)}8?Gx*+ul<(>Zx4{{JHYgwW77J#Wb+kMqIUkR~p_)boepAj~RS(R&3++CwSlBCXK+ut9q*u#gF zByi=T|FiSk8HR9%rT>C_`%KPtrZCBUDPnHujwCxA`A8k-Y|_(SmZm-ZK8ur%!+6|i{K{p1db6Nv9UB*F z*9gmURaKqgU&j+W*>8_{`)Ai#V`V0k#e)1C<87zIaI06Wb{e+h`=xFWtH)-ad1*>V zntxT2Er0qqd^sV*689|guJ+y(9@ThoIVDmnJ{cdO_*lYPox86`F|70tuzm}7bk7JH zyHD6>!UO3LWruNXD2F-kiE67^7Oz%AKU?cA9(`zi1aSbmhs(}`cCO{hrD#K5}`Cm<)=I!;8ZM7MDbDA!p(> z128Qn3HMu$w}w_(Wl6a+?LJJ-oBjcl!k29xfAEvQiNdZ&6MFc+RTvSUWxcI7**x~C zZgVI9*|HNYC^$P!aVK!s{bXS+tzyZAC5InWz{pk@#|PMti=1v}&8s~yb>(JfH~z`K zNY-l-b83G*wTW+9>AB4~3y@I*N~}Nh{58jh-eG?Q9Hda^BlzqNK(WU7zLAlOp&1-% zBJp86IX$KI9S71PUw7m!RB&(7F$*J=((*T*CB>wC*nC+dx@W%a%&lBP^h}KHwLbne z=Fa)W;<3vF_{VH?GR=q^be9|Ss9QL5#RdGXl4=EX-kIiJ<6WJ`a<^~-8srxt!5zjr zC*;!Io$$7t9ELaxBCy%W=~7LVCfHwVq-pC!2fsCM_}pj68|^O_H23QGgZC_~kIBQ2 zO#7Qw+l^&J>T;ldUZeTmq6eX*T@cyL{s(x?S!ck5j|6sO^Z2JN+VW4(OBp2SjxuFz zdVS1QyDOQZd|<1SPcovV-|c(?kirN5&Mh;}m9oAn2Cea4{!Tr4Ys6#CN34hQ*N=vv zuZbBMLq;-GII$Y4tlCKRFQ;tXZlS>q(QA4l;bp}vYmng-Aij^70(fpMUp8T;|4>Q_ zxP?rwIzzX5xX~o#y$>`!B!|@0{@6TiD)PFySi09@KBqoYb!8QA_3WVJgC__$;s(hqa&0sbxc z4CawbprSYe*z(Wwlt4T;w-+Xsod~bYPgcSp`LMfj@QJKacKj?kE&FmL#jiiZG8;RC zMKg(M+Y;cs@^HsG`N|I^p>~%GhLDAcud2mF%;*XoBc@%)AE*eMGP){D5+7&gx2QX4 zeg=@22Lv?b3nA(D5w%q@m#2q>9yrVF!Ar{8??K~WF(p#*r-T@Z^%U#Ya=r&4a?Pq9 z!kIWib~-#hkQP2gUnFk4TL)2#N#tbnz7$vZ3HxcOU@=n~@ehxkA{g`MvaCI;9AG#* z^dGbEgp7$*PlPXri)4N00W*`%uk;2H$SE)Z(5G78!9`h^c*dG2ki@KcS(psKa?*la z?LTXr2X402e+BvhXg^~TM)m|tr5PM#esehHH7B9g?)z~Xudp^=K%qbQckXA*_@!|0 zhpa{yt}N#T&TW1}=?s6w%jj|~JXp1~=BVt7^FSqBU)?HZtQzz}Sxp(wAO4U#VM9*y ztq8Bka5p)acFt(4^7L}~C_g9D7D0}x6IrX#I1zByS4(Bai8+~IIp$lH<~)=o~FrSg9TUsu=D{e*6_7~$+&+kVms*If>p z)`_1hyXf1~;tH-_d9B^yo0Srm4U^R%?-Q%BwCQ{H07GT!1{wHyvVv=ypBy%JZ$e1% z&7*d+T|k>ke(%^vh+Z27`4_bI@;}bl*jJkj&C^fJr%y=hBYOuuZpQuL z3#CuGSKuc+8@BWd{R8!y;{WYfR_YI8+2{TKYrR1nr`D{OtR44maRk$3cqlwa`F$dhSug%<f!6 zoFG2QTt5DDXVD@i``V%EdDl`*GzF;!%k2-hK8Qxh`CiULN5>6RZ*o++ysqq)POqXa z3UnAyv{*C@&q8$aH=Pc4CWgS{VL{#|Rn5ybLbbfe;2hA8o}Y`Wzg-+HPNXjz!*^~b zJtd`%c8&gK(44CAX{)H|Ry35bDjqwZhXC5F5QbkX_Unl0b6&D@$-|=41TK#kkM8ou@N5oeMN;o;e)Q(U5 zj|U~|S!{hA;_k#3k0ohL6aSerz*j%+a7a632)Ej|vCgn+j-EOlIy-;%aI}n1ra}+ zf4x_)>CbCXKT?{a-U+$A)4@sC@cxo7q3K0*$Te}w^teU%w(Y$Fz}%|n#x9cL7ZL)T zMx#z8K0#Jsr8H@vYQ)%_D5Me-B%ES0Nt21rVIig^W1#26_HgGz&(eB=AS%1>ForZ` z_ei25Pa$DdmisxeB4{7QT%oQhrrRA5wf}79`paEA@;@tnguUlQSG3;E! z=*{FsSUJ;b$HDmZZpjuyOl?)NaVwt?hVhJ3wG=&PQJ4A8a=T2syGv5il4$RS%kJ{s z9~O;R4KYNyD`q4aN^2~UW0!l&Qsb5@6UY0SI@%epiW5cGHosxJPYT}U@pJ_`3*R}o z_rwzVwZ{!-Cl^Lhw2h&4A%^Q-xeusr6`<(E6C{~~E0{sN{2q}wg?O(d+?rL0&Q?hr z5{{=$sr&R-2*c#cV&fP1(Hyo}<@1291k!{HvC}O%P=r?n*{O)+bOHT<2L_m-_ncq3_&0G!Bz}A{72cUv$w76FC5Ms~v zDJ5ecnd}PqI&NVDXnN8D$Tl&+rukG?O&BW6RfL7ZLGjN5@OQM^alB1$IaGZ1qD|1e@NDws z$HMMak@(rK)z=x@Aq|7C$OErxmP*LvZH)V_KV6=&>{b}uo|o`6?0TK-Q8&1a6IYfP zUe3}ATwV@e7|=?8ImiDdDI%%92w&~7)QiJLqI(K0ImXI@pmNHT`L{7YcQ?O{R3^G; zQ{Tzzr1~#2vRCzCVf;XZTaSJf^(?NpW7Y|BH8R}e@eMzS_a8k)HE>~joIgjAq*pmf z)t)EniwNSDh)xwyEBmPpcAQS_M2sn5_qgvuReYEYZFBmb1q#XTZ%AN=WeP+4TrjuA zuzHvf-N@Z(XHwesG7bUA3n)Q+!A7>Yd1TtXC0X}oU&;y;n$2itboa#&U-mEU>{>!i z;&!Wg0!7=6t9ttqMQM8}19>M#tDvc-GIllWDPB!xbqDg8fx*%HtG7Ea@6in)gG785#|kS<9rkcg*Qx28Cw= z8eK|B=8gx%hU1tBT+@iC)Yx(ct0l_4AP9**h>mCKBpqAjo|0b$6StLZZ)2o?BUk9} z>-82UMhA3NEO5jb{;Cx*-@`*!q&%M_GQ5ybtDLf5*NPh#osGDm1xBY6a^>T?AimJ{ngIk z#Lv|+(bP%6IaraPl~u*oG$J`Rwek)oBTE*0RY$aLGc2F)bTs}k!{e16oiYWoZ>rotT8X zT>*?WMmX9JF+MgzQv(7`pvSvsLYZYG9j1iJBhhY;iX% zTD^_RE?t)K-i9Z*3)|SJN%H|in9kr8`-UmTr6i#IK;XjHu{^}5@1!RhI(w~#f9#-! zwxB{`HoQpuju<7QDi+M%J?{N)PsP>#Cg*L;Q7i03vNcXL{}V`?r5~PS8);}7>)Y66 z6w<6tyiQfN*N%1tL2uh{|ewgZhAfMW;Czcu$qe8 zYbDK8ey0T%bzdR>2zriLa>vBaiF|hd>vD11$A6a&{HJU|+?D`}`&44_#GmUvuKC!ZuoVbzv*g0UK+PU^EMj-y8;s zbQ=s);sGx0#+oKUlw2{vDhUlaZy7S3?y>pM+xJ*zR?ip=rEQ>_l-3pN2W!5FBD^?Dy1q9uK!NbQN{@p$Mviv6ND~Wu8-HmLW&jvC8Ykj&?Js9o?b$a>Pxr?c zdA|!4=IQTyN$sl~2K?Ryk~WzKf`u+LsL~L+;=&`>yf-4U0Wg;DM3M6>t?-VMno#=b z_`Frp8f*>T0}kT@`a_QP9kQ<+?#-0jYh!O0570DaTx`?ZjzP1(}%%Eq>Jx~2laaCLg9*+(~@{Oz}7C1f~hW3e-lZb z@+k&g$C@!MG83(KRtOPbKXZ!C3+n`r-i)CVejjk4E?}8p3)TZPOLMu>8^3D*Bp*!3`t3z^Uvjf-MV2M3fQSEnSDkfMx}B|b)V<~dy{jVY|OIWk40>6uCP8no~TjH@rkf`_1r1U)u9m9 z7;lW>@x*=BZ*QD(p{i~mBa?w&(`42!gt@_Q9zsIBa}TC*ECo5;Lq5?VH+_WI>B4-l zStpLBI*uWr-#idl)Xe3bAPM9F6S2=E;u!99)wVyXYwN{!9@+}c_vi1o5HK8Ra4K6GavE@|Z|8ehklglz zhG*Y$E2cd~5%p-Hox~-T<_@!W;&}QHM&YjO=3+oadBzJYRrFJ65cwv^&oDUTu3caU z!lgA2Xs7X~KF7I)iigv+{J}2mL1GtVBymA)#pBBzT50Vg9N+dMcd9E%dhN_W!EBIj zVEo?X@|0?*%EecTaV~KCA}>WZ3291N)luEd_QI z3?9Jq0eqRq7{3Z~AvacADV;z8x7&#wQL>gf(x^uj|AdLZ$2{DW_A`V0`mva9HdQ%n zUcEcf|Cqh~!P{1bNGs!m9yKD%wo<=fSE`0hoX_09-G#;8*`Bs;D@zNRW%lzfhfMl8 z%K)B9MOPor2u`bq@uK)z%fI?-{waR0^ilpzA^q2{qqe(XN?^;x+!Hw!y)tj&R{|_a z-IOs**0a)f%KdIA2!WopDlhncc8tyI{qY6y_Jx0Z>Cht={`|2L;S4?FVU;L8lD7P@ zgq0%Rw{uwUlk9msCQXk9KQKVYC3*9$Sj6WvCdrEAkLvxa0JCp`C+>-SI^BN8$M0DD z_Qr$K4XhKlRczE?CIyzTlBGD0S?RDK!T32y*r_0ajVbQL#EVrn#M^vZ!ZnzZG3@S= z^9t)(Vq`Vyic!-6fllb=bpyBLo4HO~anrKI;mM)hOcG4p=B7ggvN!O0nRZzXhwi7ciomlxtT-iBnE>IS{Ri=OAG&C(L62@I-N9jSbVkAFvO{ko8~^lE#(P3MncTrboz zAA0{Diz@V|?D1E}sjgjI#6;rqD?M1A&HR1(%Q~hi0=ZlAc2!$(lb@Gy7}Ft!&c?!WPY zwdkkU=6MyiUD}=>0V^Tj$4}TFa_U^|&cm)XlvD}}jE$BvFauVFLbi_{{V^V1LdmlaE~R>nDzVip83?bIFn zr+dJIU>of@;v;yYXoj#YKkQ0U%9}wU9Rcdm>-o++v1!yw$0<3|9nN9{scC3A`}9#E zHic;K+xRPS%RC25X4|~mstIp;w+gyu2DO~#ne~F(OjM?>nXGD%U-;=y+|IBglIVUvyTFC^rEV*R=Q1{Oc@Qv zA>b;LN;rKdu&v0uL9W`u+)v!N;5q%SYJIEP@xy3{{U)ZCGeD;ZqmP#E?+Knidod+t zV!@;hbgdQVNWdHx9RajXzvU9~{KID^QOU2AMXcy9u)m5)4zJ$yx<0nl8;%nOTlx6b zdcWcBL`?{DG6O|xV!J_?(T|4|kl43|S4I#jb+_go7hLeDCa}av zhDB*j_4aUvs4YuVZxwz$Xlv&Bd`{iIHaW!Pb_!eu5? z`ytVjqFETrdYa(k|zgqd0((a-`vghs?;WW~PVWh8%a7}=9@tJLrm`LU_o{)@@(z%aoC zr%zM<*}2{V5hj~@nW7W_gJL9F72vD1k%uD@zhg#mpUMJ<=^efEN@fujMI&WnzQSU# zP8ocW9UVwj`k6)geO|Rky8m_X9@fW~ng!3gikG(SmTmDM9${OClxu!i>i7%!|}t}d66w31J=<@aHR zgp*vCIN9e6?T?&x#{g9_@>X&-&sB6f-)vsRu9}=t0|bed zwx{$ZM_bsw5gqs2Lx{;~86NKB90R%blfrz@`oQC*oOpWFz_3Cj(HyY)*-|YHsmoaA zeMa|eE~(S{PTJ?0{Nxe%w5RAYpDF-h&sNh#KhCS-zPUBd!%bm9%vo{WaxKJubqxHW z^+XSs|F~S$6xO|D`jh4BzP346adE8O*OH?gqx#B#V?mA~9qo8rUBpfLPt7uZd|4Oj z-CS55I2{-wqeKkkO}+L~(&}b7JTX|bn9o#g{3qAe@kxfj-dD*mosN>djK z%tI(uiP)H&wB1b$LIYt;j<1kqJRl6|s)$b&=0T3piS9zr7MO}#h`*>Jta|L6Grd=4 zK2DX|=eyim+Arz|qXv{f=WWLC|tKfEeJdMjNX9@s>sy&^0&zYn$5-voQOmF!t<(d}r zha%z7`GJp1XWkgI@fv%3s?F|ac9t;6Q2x_K$TNE4}si(_PWH!8xB%mdxa0UsD454ecIE^ucSxX zxt4?Dj_*X-bj}w7Xz(v?H!h$+>4T28gO8r3THT+A52FJ`G@);q>76Sn<3ip2?Ii_O z9@q8VvH|1p@it3wwhj0uf1YZ1;>$qW&0zl|cNY8l)6UQ;%s6JEx_%2bMcUc}78CNY zfya?%uK#d%uI3WOr^d1c@MAXYXMORuq#1nS@fIYm0y&Y2r-6+%nbI z={Ps~n~r(P8%s3wqeke^RAZG;`7xt*=qtfY*eWo^SnQT`K!~@*NIH#BS&v_EHWdMT zhM_9&+q3HTVS)3wcF?|9jt^iT=0Op7uzti!&$Cu9wp7PvDgTbaJQ9D72pG;;Gz}RL zoUK`O4=;Vu`z80S@-v}BqcYwDQIQKL_XFA(4faxns*je^VuB4eQoTiQ#M@5Q zu4{L_dYLDq%t*mM=vvaE`unm_ZT`o`0yJvAnE*S5xVibO|Dt?z;V(deb4M`tlAS1% zA9BUjvLD62r2c&Bu%=e(nPORfDUh7djwp7?P;id#c1TsrX=+(}KoVtm|49U3tkROY z*VfgEhBQRZ;Gw0!1@7@b(k0zUh|QWT=dj{Rj5214T^;>`(|M@PmQG2!&<|HY6uUi~ zzQ(`Xu@J{+GQK}2s{CSN?=1aSCYITGqE~P0{Lg(kE5&Y;sT=LiXZ*;(68}P4(%L{z z_j}J?{OVL9%?>~Gk+dq!NUTkMyahkV{|TA|yL zomz1@-2A+KW`F%g*~@qrX{T~D;qoRm)6Uu1?}!lO(6Earb`rX7KeJh!TUEB5zm7iu zT_UjMrN1%6{oU8W{5ww=V(cXZ(E#U!+Zb}?Y|X{_&?e5?mQ@rvtWG9?bMy0&r$iK7I;`|F|g5N zM2B{jb&pSnCc<^lhL$9!o8s_#Ts)wPY`}~+jZnc26zjs`x1Sz)7yIOXfmz?{$5N*p zU%u5HbT*|X)VMX!1hj>UsZNc%on{&PYEOp$?3D60{kTtd`=={Q_w5#j*J&W(^Pv74 z#C-L$d$VSO#NN(&8@}ufA>H&^iCp4T?f7zB+(`)ExD8OErk)?zSm!!mZdIOh*n}k% zkcffsdUzuks+g?xbu@af(|7Os_}t}`kd<|eM; z^tm)Bb@|+fZIS-dTfFLp&u7yP5%2E)I#n94nkm$eD{hO(#EdocT14C_pCQq%0kOXY!_yyb$7}i zx~G%0ZhA?YWfT7byL?aEJTjrP%yul^fO#hcqNgF#Pibh@T^Df6Ii+{}ng&4Knp$R& z>WanG0I-z3Sjq%rv~vqJ!}MJC(!UbbP{X~^3+=aox&1ADB|&JX{d1~-@t%nj)3WEC zmJX}?dS>&}cu}}dppjpVKj&;!BEq$O@WYb>b`89j^Dc>BY+)Oz74^r@c7t^@hB7I> z>hYOvMaJkb#V4aeA3<@kP{_!*;bL?B2DF_9@1Ea!BORkJJg$SzofL1Xq6}Y}-1crU z^pU_-3DL?<7uO+kPj?MNY z^)krA?zd07?2FNUTG9?Nua>0GhR9#GIcNVe_0Fsr?aKj-T#?7I+2MBI>f-H)5EXx+ zK{4YZZ|0<3sz{7--QU?8pH17bHPD=>o-HmuHp8I4Q?Cke!3CKvCp+rJQ9|5T zLcCpY{Vs@zMZ;n7nniZ1M|V)6ofR~9&P?V~t4Y$w-Twwb)E>K&um8-NGTdcTo$6b$ z;8UJXh${BYO)p44NP^T_OyOx+bNi7hx{A$5FZ(%-TCaZE%GH}lUyXdW+v*iEsIL4xU!6uTUn9! zR#s&FHY%`A1K^wruccmW+~<^*{m?6X^gK-O?bJv6@5Y4McYpYfAx5ABwy>B~HMO21 z#wEs;6pZbe*fytq)j&l|17FBMVZn`X`GgVjyf_iW>?CI8G7P`6=kj(F4{3E^{WH@z zp{IT`ff}mmL8AFR#Bz%{L3$Ra zjs57)Qis@o#VhMS93PK=KQr{B8&!N;Vzxh7AywJD%RQ$<4J0x|)1fYcGIp@--zF z^kZQKp8nM#VWCkTWVlP^IWwz46>_K7POx*Y$ziy=pk5=18!7V5Hi`N%l1POIrk{5B zPEx(P^qun?GWjPX(GhVJXEoYB(W;>}pAGslf5uuwKM|gKc*dD1f>ph##c37Y2W>9U z*GrBKir16WR+QjLLV&oqVx0uv1|=A$emf+b_O7oI{~ZjCVpypHB_+zDmNgoCuAnRh z>teArBl`D_WTx7FbL05YnH$uw4k9TNnL>@rVY=C)X1;^cq7t7`&Sjkz=vQwW)s#7> zaw77;MV=~_VwQ8FKk`X5Vu4ZAi^R?Cb_@l{I>9}VI(kchYB(2SiSdyQup3)viGH@k zYQa9~#v~{ri&I|lC)um#es)QNHI)d$J0zL;X&-gA?1j3cIUnYavC@Xd2YGOl%W~3U zTF(4~t zp(y)J*0G4ndA_f7{d4QoD1WAH@+$D5e3IAMxp5+{_R|f~n7H9Qi5oAHg}fR@T6Lw- zthw|~V8=yq5#a!;7ipJWH|BI=NQacut%X7xV{Yl&;U8mbMy-el+qglZm5Q?{ITIx1 z`wM=jZswjnBUCRsYc!hGuF0D^lXK0-`M@W{6#|jju34%FGuSz{D_zevdk1yN_w4jR z-xfidRV$b*>sBIaoU4sG`!4OrqfcCmrTtGbKUX`)mlue`&(L12qIRycvmh|>68?g? zY7>#j+&_<*NyGnDvq5$PP(8CBUYo)OZWUMwA2D>&dNyH@dH}v94)N1*VA;FmXK#bUMLj z(ZZYvt*NP}IbR4?qgojyX(<3xvX)N#t>B61$7a(l&(tHBTDMQDE-WOoeW+n%z2@&l z6TMpR+S!YVprAO5XVaZ*yEx_)T9kI_|Euc(NoDJ_A^)5Jjy+#D#iQW-%g5}i@BG#L zp!c)+p6s*eS z4__0_^7kgTAWf{c1-ywAtghMiD|DfT9nig>ZYs%c6h%k{XfKxt0aN=uYf(}7^GC?5Uhli?5G5HouoWr zIM0H#9RC+{?-|zQwr&k$mt{dgMWm^KQ9%%p4v7Utq+}@yQlk{P?u|;efHV=eb04$-_IpkPoHz%bBuA1LDsMh zF0?|cpq6R5PMVINv}NmGRsuKh35>RS`uHq$acQ#GAz_wZEvMkZ1@3UYy1r3D}JgVo0 zNcs)dKB_tJ6`>gEyYnMo;Lk0tySe%n6TcguxY9nONy{bH zsrGpEK#oCrx}uBgEzrC2lJDh)>vK&eh+jlsxFvQ+oZ}`^ix%o5~k-61|UdO8m&4q#veL6XlqpEdKV)QE>JIXaI7ikn$qI3;Q9W$y2R z^vU-Iz4e|;=oo@tn_TZOl|t2btoP|t|2~{h+wii;#;Ml$2}5QLMFiW$@Ov~Jtq3K5 zX`*oLukgSXSCmg2P|gitUMz%R*3=VpRZs^GjS>}Vq`Al5ejk3CK;9wg%6LRq8}3u% zzF97J|3k<4d1C;up7sBs_+%O(c6(JiRB>Rb{grmZW_~CkAnyAc%!~41nZA0^vmwfq zeUuzds4{+jR4IDDtg}v->0HmLBV}QQU0crG8sOOHDxq4ac|=Utfd&e&^+@bwO{4(e zi8~V2#VeLpv6DWc*~>w!ng-_`m8{NrlWY1pYVR9Neuz;Y5f)C-4U8yNdgtpW6Qn&Y z#Mk=Z1=r^VerS3Xz4){K__?uz(oaX%mjJ8t=WE&Jn!@%D(43Cjy(1>jB>u;PzZSf2Js;o8mDUQ8UqH$~{@Kr!CXBf(3sFr%`D$@}yg1@HT}!@YbA6pgSPi4e?m~3- z^5QipJB_Z!NbM{!YbgJ8IVn?X6ZCG{Ki|BdwgOI=L zqWv$-#W=OzsF6ujY>UxVlb1xkjX{Tu&T|jyTiadkMGO1X;oK4J3pv9Uez(7o7$pmqyd%H*v2lqSi0|J858w{Ayc`cY`@be=iM{W)I z234npZ6JU8kPbQ0aBW>Fo(<6!acbeFL`0T}b#m0yxz{;q!QUc_bkHWWsEbMHS5u|n zcb6k{!0*C3yBCVE#+3$zqwGQmDh^i|S-vfsqZUu>J#Lgplw5k2gKZr!Pzr0NY@6)3BB?a%#-Zd7!$BeHZ^z zIw2Os`Tk-%iYATPcg{GX{^k@YaKj_v^gcu!XSYTJ+qIT^hxi*K#05rvRS8pw7=4Jm zP?qClb1;^P_vXk=VV92Ay2pXO0x||Ehw(QRaY@u@&Kv%!M`tRy+k%+I)GvS1&sop| ziE%#7_&AD*C^vxV>~`PzMLCO7ccz!rYB{&PQ+=az;3Pc+fuSTr7L8bC?zVCF{0*AE zjGe?YjK(_s%UG;^TV|0F4sJ<=+2$WB)ws}Y&3N%qX!TlZd(un#M@vfrtKrXnG`*p_ z5_kDrW8uuLBxA76J0!K{#HunhdG9)x#2KNIPeq;O>ipRZ(|SiZ*M*vNxHuQYF%fm4 z<+gaWov>{;lHn0Ml@y<_eE0tQ=ICS2W^4;$bQUJIlK?@dCNcX3I&cC>0A_*x`Y1QO z6dlOXnP$R=0ab;hqNn_&Bs5tOc6}x{BHUbjG%3uMZG|luo!KjtBB=h7i?HhSeATmy zdoDp}lnRf*)E*pO$arxN(O*1N6Pr+%s*}x;@op$n*nU~*21x^LLpX~5GzB`jN~gP@ zGZs@!czRC*auYcmEwMcF0Se)diaz6WDK`52tvQ$M#V<-4&eEXQvkiatsvdweIK+c| zL*VdlpsImIoI)J54NxGLs z?5lAXy_aY;7PnB?i|LRVdwJDV=|(24uMcot z)}0tRe(RQ;_4+8G1jj3BvI?vYmm0C`#ch-4H=eiwNM*?y%$W(;r?dr)=Tx-z+0Vz@ zEFg9ZE#Xhq(k?n@34~!Zz(=I;mTsCBI>9qXB|O4z2X$7ENkkyBm$@P<}2pzlaT zk`y#@`r}(*7=t5I3iJqE)5T%MOX{lIuO%|IUUJV_wUB%27`gb6t7*u0;pnQMBeL*J zH-dLIwRJ&O%X2=a?9sh@5i!1B|yRc}XTxQ4Wu`K`7B5{&%rjHCxp zpGN0ict~G}$OnYPj(mV%pHVakuwB7M(}>GN^ucD146tnIAQ1q*huR(nD?;WZ$5MsP zk#b{Rjp^63$;W;vL2yn*SQV5wQ8uQo!)m5cY>G!RJ+@@tD{O!OS6m?4lnQybNs?o zpAp7QPvoa=ui)5QPJHdy4v{yp;^ww?EaeX{U3z?x;zt&75m?U)S;O1%{Fm>V{ZNEF zq=U4EZbxjIU2VaW zVUX`Wpy)M|#4Il-_H=`La>ckQn~}xxl>;#(=o>(KyHxxrvPO;n%XO;kincLD1$}|1 zA#qQVEN>$0<(dXL2GrqRb^`0aO1;@(2_bU4LEr=4+m+u}VOwz2P*N z9pBa06{368Ixg4uyi#0_Z)h~39&~bnQ^0?d|DqMC$*}P`^iYH9M7q_eYL?LVNl@UG zebbAD!xcFYOKc`us}C>4J@asSD!k&9jQ;}g!gA2Aw2~T`^Cg~8b}y0Ga@Que`3!{} zJDu;Z3@iZ=e+e3gNE&7oLMRR`1b|$A#v^QV_4LmMtECU$wM_@~6-0vpjpFuu)LjpE zwzbZf`_c0wow& z+?_Mrerfd7*>(!JeG@kev!{wvJ4EOU|7D!Qq%$y23h{zb6 z3T>IZV`)uOOSo5gSiE%=CIF!S9}>kXlh>Fr?A+Hh_Qg3V0hP(YE}8Fg z3dAF9uX4uMPWNiENpFnydBeo{&hAP78&i?od&;~{%j=wCrKDL2W`mhP`m+7p32Gnf z-P%>jA~BHBe)MnqYAnmC4(|V&e5M!uf&CG1QL2)3MY3~SFg5*fNbMk(Z~$>N@{Bqr zSo4$m?pB2yIRsm?POXbPD#&yV*#4M?my>eGzwVZV>Mb(|Ql(85!T7>$Qurs`)o&nq zItAA+BELc}=PaO|D`4P76?Z#V)cg+IPOThFjz>GYOx2Mn<89ptjqSQI%c8$tA}%Eg zZ=U`0#}4#U0}dmWE;Lbv+vdo%&mXL=ug4MP1ydiFr2n1tL0O93VuP0$Jr}uwi4gr} zyVDAG*<+u8#%9SKWu=a^k1bD6=nIezs%$kCj{r1KUBI%d`oSHb9mB>P^Aij8aD*IU z!JpWexMOj4(kj=gb@Z9pD3{CH_Y12s1;}rz3po*-dSbvJZW)MEU-OvQ9JWsfn)bRLd}Q!igCa494k*=#KOORz}Cn|VU7BZX0qR&vI3Q3V=gGJf5;y~|{iRClXBwTFOoKK}}e7X7)7bGiyiXV9tYw7Bcmw>wZMuryW6maPNqi07jL`D2e5L4>y>o@Nf1 z|4s8yPqB`GrS!|sqXY?F+5t0Gn^Ttt=G+2_d+XSp?{Qm{!x=fG^p}e}FA#+l@fF2@ zk$|QzD6G6h5;Nm>{1CAXun7Xe$%@N_j}ZOj za7a~yU0IMm=^1prunb+ivwG8|6I+F{>B^)H=jGqu@750z*o%fb2RYiydB5&dtX9s? zwi#5!2J#r(ACRq_I_mzJ2!at+*ghz334i1IfS%p?M!N63B+X1p60_U9|MS&U;{F>t zkdj8#bN<;(ldB42GeQ39uwX@B@9fFq8MX|fwIwq0&O-KBre?ZVGggAF=rUC?gT4Uc zmK0}TjvuN;_zEPsTP2n~Bv+IT)5y-U_Q2211?R5oOvy0Hw3vH?J(n)*Yt!M-egkC?6#2n z()YGGT$JlD@NJI{Xe#&YVh3gc;v}z?(WhWADFQZSS1)+JV_}MNdjamq+->`TF1r%! zx%e~h!Y7yzQLXe#M7sa}1CKFzZQ7BWLbiV2ygD0%KM^{EIw$}t_uH;99Q{!TV_4h> zD}i}lX8GhT4=b8H;w^Vhbn7P^&>F3&=pkEHA+@Oq9zhA?m1SM*8Tns2fprmt)`gXd$-8&y)?VjqVJiqe6fxzS!40ef zbWi#B4LD}x&ZIma(RKL){l&pc?3~zN{8@2*?#U{&7}okSk>JU`U`hM1DGEG4SoVdi zUyqx{^d~&J#`&H#POI>K_~F=7T74bj;D=2sG>Zt|b znPeCE$`hDC&x%$mKO!>R|%0mW-^Z%4N12j0b zpsL1u$!Ua^R_s7?@>LTjRuJygTls{G;MSHLLz*`iMF_uSz z>)p$7>n4O=S!-l2o;O`m>+~*5Lw?CsY~kP=7lY|VRCgg(>FAPKE&+Eev`70$|H6HZ zm#pMu1TyGb+3~oxN4Vg}*~waWq=q-WU-dsJNw_(i4vL!^PBq@w7d21kr+7QnP;7QV zXMh=paytx{y>V<69Il9LB`&tr<05#{IDK15Aqx?*_g#YbSgs(qHwoJrVwCgZ%3W|v z#p+g&%~^FPxehewvS3NfMwiTDvuJ#rn5<9>r-9(yoc({}O_InD@Aj`O+$B(LQ{gGgt8sanAy-x%Qve-8y}`e- zOgZoIeqK#2a2++H8FpLmM;l-}V?;0MgxA`E#RijrPfZiO1Z%ONySw5luoV!I7;IFdZ4wl{uLS#g}tM zHwAiv%hg8-Rw56f4xF{xf~z}3*?xVg<;zDPch2V*W?8;bI9_+Qro9YJ9sZh!rVqDk z`Y$X{@_w|vi8$(?ynytjI$Kx1qe7i*Uf9cBK6v5;7+CU9-x(#n!0QEC!IZ%p`cck( z8mKGGDz|W~Bq6mrRz#)ON$k=n&<)0ajWao=hHi*2XP~8N2CY>s{w9EIfGFP_d!_FY zOTn)lpO zA&($-h%YIW|C;3Lglzf-v<)qI9{Qv!wDB~o=3o*_SYf+3%S-{By&TB$U26eaOpZp6 z9@LgXT$A8+NNyPNqk#Zb^CvY`@TCXUrIP9#y&BjTprxS#duRaKNMa1sbuqT&2?%m4 zmE}b)(<~O%E&-$~hL4nc8zQl%y-M}F61XeCTcV-STy(Rd;R45p+2dDEweBh_8*a^R zQWawF1qlwAKiMU<8RI+1uud9kIF;>LRg`+ByFE5N^fG4@)dLP z@@IbKnRYz3O6(@JjR-V;3WurZ0{sGy%>=&!7;rcPw+rawt1pb;ajj4H<`M(D89&7& zj;|IGXaw8iB{2CpG8x+tKFbYN5w7RgXAdcKVU7u~$kWHse`;+Ec`LDL_R)An72l81 z;c-X$HMRY5|<*EVR5*R9L_1nal?JQ*TLxeD#e3~9R{q2**9 z6DZ$%?(Wx_!1L-iFgw-Os$jQ zD~A0hJaGKLgdHU*sWN$UEQn19s!h>LQB{>3)H zPvzFH^>`R?%Y1}e^armC-=k-qzCbP0nWi>uO_IdC zS)TLFw<*S{;hNgffnEtm@`CWHES=EE2+0o!9`aiSc->+QcN^OPLbKv)0NmOS8q3H- zke@@v&@O@T2_$Tur?sMdum6_u$9}AJyMyc$bRz22$zsQOsOvIwq9_O&g z(Kr9n52gf5ONUSa(+r+DZk_a@k*K$zjj8cp6|!u$5t~2v@*Q%pAbn1}Thm>J&28V_ zG8dRPx%-Vr(q8+2%L`_Fx&`xst%&C1dol;?a89@AG9G70(;D*4%X*q}DxT>d-WLK$ zF?!rM#)hd-g!TY8<>^9DIX3%G)>YBE*3JWput~+Oum2P~!`FRpg_!T?6_GWyy9j>GfaER-B%40^C&c;#uDF_qNm9Y=$C|vrKNlz12(sEWdyK z%47=?j*6TYk^^qr_c+=iOBbTD%l5=Oe1|nDH-&9e-hvt7*F&o)4yx#IWFs>fP6b^L zvD_91_dq?)j`+I=2zDI%ts@aRIeYmGdEO`(&r1LxvAB_D_L(u6TK=Riq*(5`VZ`32 z&ItsWLrmk3?{!UL3}ESx0fobFZZQ6zrkgQOY8~mZhT_D+ZB@&-rt^4!S*qS+aUkYm zDQ)vCX|;ajaS}lDccYLgxOX`s}PtEfu`3ZHq(%zV{)(< z)gxWgO)nZP8y~Vj$VYw6zP>KY6o+!V!U^w7^d+DI6@k=?MN7{pPcw3g%?EZj;bGvC zLVxAb&R79FM|~l>tf_BDai1FYd(tlo=@DQF z5G7V0d|9wl+S9vXGWBW31=|-gqfy_}HXhKQ(J42?d;ocuW4)sYmCC!LXRhV~6oT*Z zr6+M_6KJ)zYiRDTDH<%@>S$SYDv9x1R zol6QdL<0Kb!(-&W>0BJ#eJag|0d7DC7e-{$whW4_3Z?O>msUVCEIX-((*jO`FqsHFlZfFefc68|>$TSf;29jLnB)&{Ok+wv8 zw;B#3Dl^8rJwy*2>Z@Mp`2dW#4}h_?EGpSe?3N2P8{%LBn7;IIa}Xg+8r^Zrp$pUS zc^D4Ip0TZ1rZbUGm^4LSnxhlQPx!YHVx}iZns-I}N9@~b4Unei(*^o(>fb7c8r~%? zozK@+yV~Y8s)TTY)op7|7<)j%?Ualm#_r3!!JcAd^_qvl0Uc_wQE_gx^113(iFNUX z=pK|y0k@)!lf#V0;UXU^->5AcEp$OUqd-j#A1@ng=C=4d?Wi+LG`xL#Wh&&Mk=Hnb zd~q)bq#Q}@-L3Hsl5#I@Pr}82a;L!F?T=;xIch*fv{ZCm?iru4{IQ4;>!(@|OUmrF z=HB`3Fu&A|z$Eg&>-FLT{^j)&hpEh*X`aTK|A%HTTN<*Vj5OVwuABf#E2Y3P3ULXd z;On;wYfvc}>qSUKpOw(zTF@)#;@hI|`c`08vGtG<-o0*rq3hD;SKNi=KqvOAzq-tM zYbp+emW1`*v!^t&N;PS*@aQVNmh-l*WTGY}$keWbktTwQ(0Lp;*djqc}Pw)_|OlA&JiQ?J7nWX*;b zAu?7$b4V-03W)Z@G_$6C(1cATC!XDu{e4socecO+@OG!+l*5TB612|H9*!y7f=-!U zA;aEL(dS$gONbY1YxM|6YzB-5A9viL2CnK+SLc1R8%!M&-L18^7M+sbk{Uc+tJ0k| zzX2#PhSsr=1cC_$qk5P`4}16hrJ3HIijUyGw(P5J_XhN%W@SMjgyS^-_S{NhjDA} zz3MIoAF55IN5uP`E34_I5QQuPxf%`6bdf+#55U0)E%8h>M)q;!ORgtzLCe&H?h)-pBLe?v&Ww*h6JwE|OhPH@~Gxn%r)!nkF0U(`b9 zQU-wL?{~Kup6jnsUjg$w7-DVJ&WRT6-XLx{YP>wqxdW02F{?kDP~FYV_@>_K{DRoM zB%bbAR{+(rcZcoKcw zx`8u8As0ej+4t5M_QjOsX@IjiYbw=$y&@`5IzKR|qz}B_8W-l+dSDjFHxXRgj>yBa zW1EBvj*DOn^^tl-l&oe?*)Rt^pioU-U_548x+~#(6v2G(RY~XSa&bYsg6UdB05I9s z3r9SjMsO0Fv~@_(fgT}}rdHji?=8RT6kxSZpqr>#f8ylzx0r(#kG`TPndt-803qIE z>Grn!M%6Ha0RXhS(>5Sl%jR6al5%3UfbZ}xbAaCTrE+HrG$JTq)^2#RVWZ%8mES9Y zq}ht>v;GlPoXNq;L6OI9=7!&r@`0YSWYL#Sj|zWp-Y97obZ3W7O( zREh*TL@fkgDc(D^yFNX7_T@hJ(`#(j{XP!*AJCIN5_i(xZjjnDU+e67i>=7>k9CLw}K&ItDW4uC3VFBZM&AgM&GYX`xi;O940N2Q%qy{GbvqWw$G7{7prR+LBI*=T zm?e120@D*iE$DMxrA&D-x!XQvG$JnG@!>a^TSC?=AN(*qic)_JMtcQCvSgC_!aMfqJfTNd`rduzRfU zd#_-zLCbjut~0C2yIhM@q^h)nF3KfjmMG*X)a5>&h-d;#6jdgD6-$HCkZ)@|zA07G z=``y|+=*(Sb)3RNoV&Gr>LiqF>Fa)hd__{VIZ_aTjUrx8Hpol%Z$0E|zq`Qyd~s6W z#U88zCzca9N9Qfb7SkB;&9@%y`x7OPay+e>j7WrDVU6uT1fA_S&lrsvEBSRFK=4ZR z8>vHKQJdebuaQx3{&tYxBue&JC{#B}T=t{Yu`OQHl0wa;{Qzay`?a3{g6U%}ql5C} ze80G3V|qMbQYh=1^*-e4*rh9IFYbLEU(V^ZNjYZj?}w|NZxIKI-K2|GvA(DdPo&XU zYt15ACfDR`j8@6T5}DNZXju-OlaA1fue+`Sj=03aqEQ-H^%R`IF?&{)l}4BMawidz z4PCN1ZhnI$2|+2x(*HK#??9cTKW=Rlih1IeO1|f{cBHsKuyJtNI^S1OC?#UqnpMD- zmu-@%=*%spFHmX#X*6J^Xv##GI-c+ev;{eIEXZa9jkLWr-F`x()n4mH(sh?O|1iT{ z3%T{!HgrL*hh(OB?Lsd3YEk;bK76;IL$ymi)+4j06)?S^q(*CzOPwi#5G_-8j(f_skJQoSeQN&(6Y=gd7Sl^SWi)Pmr?xXt-Tf?qK=u{V(DON)idnAe&LD>Tv>g9Lm$lZMP2S2duBxZTNpc8cGrvMIw$^##&PJ3EuD&A>be?46#iu+@C#~7m@NXO?ZfV!=3yv&V%tdq z*F?!$YI?7uJ&jzK$%sA%}fT za`GR9m5&_N&_m0Yy0i*`ok+s3gRVBN9!E13flj)=su}7ZB&dx?7|>Dcx9Z}!TKWMm z0LaJs=}rrDpdgXJG9ScRDn(&9K<=C4&jbR#!$0HBe*Jimjl`mmL97M(Hs8&BvzPx` z>t*Ayz3t%7nft-jjB|(iJ|O=3DuI**3ks7}Ics?mIQnm=9%_Xp;BvPp+}_0Jci~rg zJ6yzb+SQi*HPBbQw|_KCu6*UdQURpC{hJmi)O{pq@??XoI1o6%|Le5?O>v9<9~nTW z@7{m=KvN_Ni%ul>)zi0-e>U~{s-R8Rouh}K86lfrfugZQGG% z7-fUCz09-CuMka|&vy2{PA1nL#$YfuM^;PLE^j!`o?AzF?y{+E=nl=2EIW}`*)G;< z=T;QJ1=p+>&($PBg1@#nh&OF^zlfs`Z(c>kvdDuEjjwZbb%ed1#$G)b+a{mp_Dw z%Z2xl4_2eCA^n=>3QMKsGMkh-L01T2F5H=uX3Ik=;)oYj@*We4K{iph2KJ(C6kvMy z_seQ^8g25veS~xn(QiqX)IkIMY)+t*W-OEZ*F}w+YTo5>wUv@LFL>eoEBbDPI5R3Q zhlLrug_DH>mV6C1k|F96gj~robLvJC@i+~XRgb4Lt510lm#kIXYw3~65P-V<`wNTx z84+T{pAGfcX18Nos3qXN86cG>{n_@!oR-w9DL+S_pBFSlsJjmlfx3?XZs@W=zntsbq1&`~Hj$Sf=}tTDo4duzMOomi%0q6Mj!@_GHdYss1thu zt4o%qS@#SqQHKP7j}ph}?6vdUatTqb^e16}`b;j$-E28C0+W)@p}lU7Zw58-z4I@6 zUCMdLT=!m_T+Yk+JUve0eUBcgQUF4RKPgC&wv4L?G@r|9dDMsM0D>QU8x|Umd^t49 z>tDg$*}G-8sCcA~y=Uk~pKsS=rHQWl4l5e!R~cu%vm}~XVdb^H#KX@b)Y>crX7}^H ztK`;|h;9{eAk@u}ebh1SL}2do5`fIA>1Ag()8#dZCDC(?G~bp(^L|@^zSKX`hwmnfpcI?Q$WB@TP80Z z7=`=8`l=ZXTRx@2swoILnoo_pDLHN0u58E{2HyUWovJ8T+V_b8dqc|O zpT#ne7n1-{1S=kh(;ZMf$;(oEzRA33xs@>48oM{S*=oKB+bTixJ;_efvPEEd$pDs$ zrsh`meT7_k3G*_KWtkrE-hvYk?yGu)V|#I;Uq9Dx`o7{tKjX+54s}bll$@uoPktLv z1h7rxW7d+4xxF;djZ@a`Rmr%Mh0QRR?mbrA@T6*(!s7!7PuB$Om7M{S*AZ;*!TV+F z)OjTNcq<8xTsw<7>44cR{#XG5+43Md%5yGg^Zio5zaDX$DSK0hHkLCK*EhO#al{iS zMazWBTL=#r2FZxe+5+DPc>7NY56+_|fm7gDO2)U;vJp>|i#Du;eeQny96Qo$Rla@C zLR^5ssDxf+pmznCrb<7=lPVKIk@K!wokbnc_y?}+k3~lfCX*Hoy7XCK zd05>1r|TYxU6~5O_eaaE07B|8+>tX+ck>=F#GCi2=ac&xoFQM=Q93JhTD{z={sCju z+3>kmVONFNuyT#X*2KMPSh*J0Z`#D$3`s>*gcd$j{6im)Moc2QVrb^FFWx_cjE?MD zpn~p}emcgznI?ytyUg8nAxWC1{6^}<_^>Su=O#^WfW+*c%=8=s`R8>QdZUOcH3QgW>3TG4hO zr*|8#W?9YBjvxR(DX$rqzG0B4wzxQ1f@&bR-t}*yyt%*p%%SnS!*mA=G;QL(1#(k9 zzK~ShyLXQ*PqnNr&Z{j-g@HP&?Q;w4gccJ%TO%g3@rY7(Q~1-~y8ZFlx{X^~-DfW} zC58>1o#ZDAr)Y?L_GgaS9yCK{yCr5NvaUQjrT74tQ~`ePXYw}?(vfbjgf2Y>uzoAz z=#_iD&UweHr(U);C14rTLS+mlyn6&nvOy-E%g!A_`?M|9wO)42wBoi$wg$jIrjXHl z!}1C}&d8mchkjPL zAGX_cvYEkn&|+^^b_+)OD~dG{p>OysTkFI}b}Y&j^QRKpM7+E|W~w#PU?ewV=*8B@ z*5`V2&YI5=cv)~qfGoI3?XrFgf^@OM1k^1uKz+api)uYp#5L$!o-50B7PX|Mgv<^t zT_x=PO`beoAhD-VgpI0t%PHlMHmud&^|Ks)e-E#Wj*71jwh=@oy;PfFPmEZ~OnkI@ z^WfvV&qIwz5@ey*tJF}=>niW)Pa^7Q$~{KCDF{}M7W)2DYNcD*-Zve$nG4dRB}*BK zhS1)@&ZH|D3fb5cZgq?8(l81D?kbG{!Su~D06XSe*V~;yf{Hrb-n6Pz0!n(HS(HFf zXejG-b7iT0g#*X*22a&%`1FUWcmD$!I5sD+f@zt^e?a-CsP~`6z2HB9fMniA@R%52{N}!l`d2_atOw{oYi7gLm#lBGQEu&RTdW!pNa_b zd%vgguGm)@eall9$V^K<1!2#A_M*E;0X}G}0dW1biCb$cIm*(Fw&$gvE;Qvd;OFv{ zULX1T=+LVo@RO2@38IeGs}@hTw4_e=v~lv^T#gR}F1yrL$vrYhXjByaB zd&TeNKP#a}m#?t)^^r!-)kfF8NuNh*j{oyJW|%S z%PE75G;P%s-tpO<0*}LtcFQ}%y2?ff384;&5zA85;g)kT*VOHLBwKY|QcS0QI2(O@ z2vnG2*p!FU>c#!D;qSkadi?aaZasrpz==UZQ_EiO!L%~__Rrh7Yp+@HkPjG70v6xf z%0M-sD4ycGY`D;W>TbF&VS2+#vAF6G^nqQaFfjnSI7GS_JJlM+*|xt@J*Z%t$FH*Y zKg==tA5u_OP<{^$|K`#8L5=0^faMcS(B*H7LMa&1gbL&vVtIR6r`Dk6Fv3K061U`} z?S8@%8}P!JlT%?!u+^%Z+gm%=f|L}8+y?!HRPo$okDmk|!2C^KCTvmhQ(o55-$6tK z_7bB-G$CMp)+oik$_)cgj!Wk*j4PSgGIQJ)0tI;o{b9wj%5#{Uo{6S)beDOzEOy;r zCMKm!wMMIL%b9XLzw}wPEcBu8?kbJ=Vq8f^4CJnCMH8F9r?`kQFG$JRz4dZz9pkF; zQFkm0U95j(`_8JWjy53#zW6z_PZ)B0akWpEcNU&Uxnd}Pne=4nsUwHxw*W)8S&Q~w z^|pS(Gv;jCenlT1@pYzvZIczSA;VJit(&C9Q~osPCf}(K-D&(ioO*>8_zvdSCLka9`)QU>E5-Ex`-{ya;JQe zTx)WCB(ND1cBj9sbN9O2&O95MRf^7KFRbdaen5~@)9Z$jhdY5sWo;Wp*|pW+#Mv@A z+EaKt{B@1kC`+?t_g*AIbMPJzNXc&=WD0BOP&DrQ+6M%IdsT_R$gZz;pBA~V75R7m z?|a6-N)!V+>HKU)W~?h+efqS*N>2514W~DhD*QJK-uG-Kn>~Nw?;Z5BT`Q=c`EOhw zP-p%y`vcGexL!a>q!zID&dU9)WBdT4>)qdk-JrQ!zeWz=21gH3ZYtZX=M;L)bJH$Q zLWwhRM&v5hN(0Km^7CZX-oD~1=%~f*@05&tI1&;%*zXg6;l%*_B`oNHd14&u;xbja zMqk*F->rPSE6B!!dGvMw7Y>!>2-D{fj(^H;dM;F-M-_L&BtkoxKE4qVl$0^yzcA*( zyWa)?&}nBgA?C@_$cbuY zCI>?^`#m`9tE5;KJ#Xp?D{u)C8UCeqLD{V$rqctfgH2xsTd|1UP?9|SOt%Mteb;x@ zP6^0$`w^lG3uM|clyJVQJn@-Cb2c@00j2YXxr(ien4Lez5|`~LX)Mj{*Ffv z4dmG@E~?C2n?InC`;F`I`eS!Fyo^Ii!bULsFEoi4F;F%!=1-SMRHHt5L(i)sHGkx2 z!%O>7sSEx`m*3H0q%kG#-JOjHk=ut(SMNzqnFkkF#zsUzKmF13Vm0zD*4F2c zBPL7ll`M{=E|{_4g)+y|dpKQe<6>_IWR1IbBA?H1OfFV1Temt^OzT}kSqi|E;Mu9< zM2DoXBBqJmx1A8EOTezt_`@j~ie#4-k^3&_ykkF(X-)X4P&wkHFv)|nb$UJJF0iXUaSQp_O^H{P=6=ya6`cM5US8I{2IA^>$?6s=u>v)F zS>5g#^8L-V@37v%zoMM^0Ca89>R$(n0XGCB6#^*vk8SM##(&S=A}Lgl8A{yKsKQKD zMkYZCYvQD!^q|K2Q4k<9L9pO^fLuWAjpZdBbSem8i~tH>dpoUn-fjobX2VB|q26rl z4>39)(3s4F>7+}*Ly3YPvzcV%&4#Jb>PRxTvVZ6zTb_!#{0#|sOZd;TT{|gVz9-Jx zfIOK~eLnZqtV=}hea)E=2;uZCQeII*R=rh$ApjiGg%k8?EE*fBFnucDcNA#_^!k)E z45r+W&MTsOCPBgJS^`YfS4dB8Q@RqG1~w5n(q7)AB3*P{tF!vZP*KwtazuXAnE!Iu z;+HEx(D-T`m_-oi(KI=f+wq{6?J7j^$hK=wyMndE2rwRv<~RlRsd!)ls{_A=>krmQ z4GWa~Vx*?N)0;8Ut%^g@y2qg}tMbkloW*=q;D0U6NjUOky^{V7OR^4l!mA-@r)AW2~pS5)F&|Kd26 zWF-L)XSpHMA6vq~%2B(6%OwFF_Mq=yHduuR&8cQL*P@*FDaNkJlryZgcIlM(v5~1 z27q@Ol#mC2b?|##WgIiw;617_W$&ZWnKsRfEz!iT0BGBe^VUo{f#9ss%yyPKQuf%} zQEuX1qkh+alQ30+oGxgcbj{9zj3(}tuO^iTu;y}y-!b@6h5_*85r(MZgxJ@=5~kY3 zTL>rGT!qeJt_>4Bql}gy(p)UN$^mmT(;D-BBfJEtLAK6%kf8DC2Rn0Xds21wK5{49 zU9z&E{HgI#Ep9Ih8Fbo}0s-|zy@fUfCFq+Ya-O&m5szFM75xvq*X9!2;ivz(od9dE zc~G{1gxCSlF?x1qwRUSjkQviM?Qbf!@m4I;B-%qKaX^2+#*S~;Kn`Vg)5zrdF zW+fxy0D-CWrh7}`$#Yf1+i7D>t&NE&voDJfz^D1#U%!pyu4J(a-TR065}5CB z=#(2vUCr}+MatkV>*^z3jrA=MO;piRI@T;84f932fxW(T_s>#obxvd3e*$%wyhc)B z&>)*(I1{H|g#^@iDJaMITLmM<=yzjM+9=e|7Bt`<->-(gFEr5SHPeFU7n!&4;{gpN z4QK*pZ|c9Jt1}d6uq>(yIk#NE1X7V=CLS+ij)oIo)M-Ry!&D>B4z_+{gp5){r#!17 zpbkOWdNsn|{eM+&T7Eh}7mC!Ojwq+G&?>d{8GY1^*YrQge8d?hNRP|ji;dApe0m?i z=IT(%=a`trw@cFk?l2t`@`tP5g}TO}uJq5cyksMRG}#^kAnaLi4o4rq?VLELs#>4k z3Aru&YiBj}y-I>HEC@%I9R4grOh9Z7!rD9wuKsK_rGg%vtCcgX1DRYRT%6XDMfCq+ z*VBeZB&6$bY&oWh&E7s?0v{58;U>WHW}xx!>gu%o6o72SN>pQ-K{w=Cg$8-VMrPic zbo9vQYz$#K@sg5a_*^2CU*{dNVzXjZgSdi}M?ykNSBREprk{vnXki@zJ#B&zOCs)9FW z(`N@?AW?g#3$|LjFkI@OKhnjDn2#y%qvHyUDEAYifu3a_R&wv&K_Fr_8Y?>!no&!U z=$OQCPzhIAi`%=Br+O^7>K7CW1faU^*zLq8aYJeegJdT$;$jB3%MByBC<3VfGG&D5 zdm$@qA5ixCay}W9BW;yLLYw8@LSL82+{uYZ)S2YAof!5-^b+wdxUtma9VK0aZ7RZM zg|b~2-vq=CgpZ= zCf0>as&30?z8aRq$3}A1eRU1wx~8wE)SXpthn@|L4qU7gW`FjBm(ICkzb#eKQjEgF z)vlu=gh?@(>24BfJ_+?-bgy#**rLm7tjUGEULZ!TJmN~=qf%sexEU$ahfOYiJ}rmC z0FUw|7<=r$%ivyFx&Hgkt#d6H?>pk1lAIyQgTXdeyF4SmK3wn0eE4%1ogQ~Guh%&( zC73lT^@*no1AvUpuTmVu7dMBWfB!7>s!%not6v<_$ zXGJ#lGLJXE!aoK6&eQGi4i#Wa11X%`s&G*bE4>pahhIpwz$o_zF6`NPI#-HZs&~F4 zFlh(xcN{Hrr`iZmbc7*dnB=OLkF)G|9+=n6j|dEY9grh@x2W=ETY7#x5?!D*N#c9j!VxNrV!og z>sizMhdTnqdAIv|kQ{vN7&LZpn%D~5%gIf8&mk9azg-_sxg&G6$>ch~xbbb&=w;Kt z{JM50-Md9WKgZ|K6Yk{inp*)3F0Op=K=twCN_c;W8ZzKY(rZYOah2=%KDd7XG#`IG z&DUdCo~q*J@krpw9|-=_=-1I}ztmEB`i?%S6@AA_kuv+utxO$Y&?^t}&g192a^q`w zFQoDW?_X|zxc)=SRnmkXn+FW&KZx+8c6{r%R{;?avv*m*MofsuOz>?MV-?|WTX+xF zcm>zozp_h<+sM0|9qNob3IuG9B%vCdLrBL0N03HZHq*!7>C_!TKK9l4h8L(h-% zDWfaLfMd6=*jMboIlB%9Jj>SqdZra6paHMq;oH1Y38nH7CBF5%TbupH`xm~o|IN+v z@h;wGB~IexitIySh1LDrjr>0!{sHd;+{JsuKhCj^ch;9HK^?!J8*20e+2V6px%7^e zOJDjg=b!kgr}}l1UNKu?OWf7x^!q#b{eK0J0f08UhmAkT`xy2E59R;Va`iz?Wmmk^ z7IufSth;p>&=qWJ=|TYuiy2`hu3NVYez2>nQ6fAXihLPU7cG8}Ja=7-ii%x<6p(#> z3?fE&+H4X5!*xH9Muf0Hi1L&m7!YF!BqStxAGa^bR;K^7Go9}J>%9BU+F=&uVgcV%;BPJQNUyFIhOs4AY#;lm`ot3s?iVw@Uwf*g+{z1I z`X80gUv`qEz)^~0%uGE3W)Fc`TOjilcUAbAlSfY;KY1#Cr*~qLHqvo8ZHP)qs@H|S z)uqSi2bhSSz?cagO5@OG1aKCf-Vhe-AN+do!h(BV(+8!zDIfVQXGQS_oSRz>^pMs= zR}3_OVp<99`kv#z%KAlC)~R6c@($S|lFpZNK(%IK+HbuzTsglz`Jy%X&Yh4ruUG8P zDvA$RaKJ@Pp7ThwH9Y#Y+SJCc)_6?Jm1d~@wjQuXy3SQ{pT?rl$%Q$4SId2aJf6GI zcyMWGrlAXeTA0S1@1*E@ID_q?N1@Y*i`e>BK^>enqH+Z&IU#tBmgCSFx{zG-CIUq+ zeaLq2^osnNz*krH`2YNmhAu6$Wu@@Lyihz* zWHn9Joo-W5??{|vAbiZd?~);;Yg^tbv*l7hpJ^m^Oq>=J1VnrN$Cs=99`b8V*DYiD zkom?erhD1?N-)!!-;A4cfY_wzw;Z~136Da1vKht_1A7NB=4!$1G9a#Rri$9cZ|kEs z6R^8XLR^VH16=j$ghH^lYA8d!3u%Z#T{bC~G#NWU1i8+LQi+>_)q1O<4gnD`gv+N(PYWq#>lvg>qaL5 zL}W9L$rQiyc-6V6t+tG4ZsRAlkMl%7`?{rvy*7Le?LIvb@xOz#lejp1mli6o-; zTU+jmj~?Wu33vr-w@Q>zPC~MPNDw4S-cYc(rjB2Y`)!%c$+XA*WpJl7{-!qs1e*!J z_mg(z+I^a1&OoyG0WN*gxAT>i0**Ej=NtMJ*KRcrf_swk{=2rb%wHD)_z0?BD z>YsoZC6lhb=uOBW30R62vu8CwzclXHqjrct-T`3{wcp#9fmJtg_Xt~or1SbXA> zzj*#J`!CBTL*Fz~z>h20t^DS0jg+nxgSvMab6zHzm4;1@!Wf_G0s6gV z=!ILK&?#whb`2RkI*~#rMundxhZ%cgWz4!g$4wcw3j@pfa|ue}IJ|ucpsNsP10-q^ z4fF36Cy-!M^(Ny%-U@k?$1Jr{_Yd9pa!#S>QMW_+3>hdn5#Ey>;)T4B=>j)wet@#^ z08Qy|e1LZ8G_9|QNGGC!c9#?Zl%HAw7;E3hS?PNK*WogNkV;75^+aTqNh)bB=dqtt zpe9BDrL5|X)@$8$wL5AMq+&^@Bq3r?Z2t?dT{X?Ce4Dq{LLl(KECjAj1YKs99Q-UL zNfp6Wm-3vdicNU7ClO|6wCCA57gL&WB9Bo$uTiRoU)~}@q=vwjdwRg=U`VzLifL|k zCgcvZkPR;)N-GfE7FqKmh2k|>pT#eI%#6@d^k^)735OkUn$STL6g0MCX-7&ugwnI+ z(SYAW4yLe-@n%+d)0hEJlHc^f#lJSf&a@)#928p`18)kG1pi9;d5qXRD>fT3T87$f zF-$vH75@3ZQ=HUsc;gV2pH$!Brnpqp)rX@qWz2UvuA4247__4QjpDcmwheo^kuk$I yaO&9NnEyzLTrab*hnJZDE6H~n^&%SjEdO@*i0@0sOzvY%@|q35Z(5!C=|2I_u5BLx literal 0 HcmV?d00001 From 4b3f3590024433467882fe844edd40bd895f1f50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Dec 2020 08:58:12 +0100 Subject: [PATCH 14/20] Bump joda-time from 2.10.8 to 2.10.9 (#3438) Bumps [joda-time](https://github.com/JodaOrg/joda-time) from 2.10.8 to 2.10.9. - [Release notes](https://github.com/JodaOrg/joda-time/releases) - [Changelog](https://github.com/JodaOrg/joda-time/blob/master/RELEASE-NOTES.txt) - [Commits](https://github.com/JodaOrg/joda-time/compare/v2.10.8...v2.10.9) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- main/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/pom.xml b/main/pom.xml index e1f49c0fa..cd2c03707 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -313,7 +313,7 @@ joda-time joda-time - 2.10.8 + 2.10.9 org.apache.jena From 08d602bdaf1a70be00d14044dd44c9ee05600b4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Dec 2020 09:13:33 +0100 Subject: [PATCH 15/20] Bump ini from 1.3.5 to 1.3.7 in /docs (#3403) Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7. - [Release notes](https://github.com/isaacs/ini/releases) - [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 9fbb23e66..db4898b19 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5262,9 +5262,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== inline-style-parser@0.1.1: version "0.1.1" From 8bf621b1ef563ed2df9117bac43cb6aafb624235 Mon Sep 17 00:00:00 2001 From: Yaron Shahrabani Date: Mon, 28 Dec 2020 12:42:36 +0000 Subject: [PATCH 16/20] Translated using Weblate (Hebrew) Currently translated at 87.6% (674 of 769 strings) Translation: OpenRefine/Translations Translate-URL: https://hosted.weblate.org/projects/openrefine/translations/he/ --- main/webapp/modules/core/langs/translation-he.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/main/webapp/modules/core/langs/translation-he.json b/main/webapp/modules/core/langs/translation-he.json index a3a1b6330..731967e14 100644 --- a/main/webapp/modules/core/langs/translation-he.json +++ b/main/webapp/modules/core/langs/translation-he.json @@ -163,7 +163,7 @@ "core-dialogs/idling": "בהמתנה…", "core-dialogs/updating": "מתבצע עדכון…", "core-dialogs/scatterplot-matrix": "טבלת פיזור", - "core-dialogs/focusing-on": "התמקדות ב", + "core-dialogs/focusing-on": "התמקדות על", "core-dialogs/processing": "מתבצע עיבוד…", "core-dialogs/error-getColumnInfo": "שגיאה בביצוע 'get-columns-info'", "core-dialogs/no-column-dataset": "אין טורם בצביר נתונים זה", @@ -171,12 +171,12 @@ "core-dialogs/logarithmic-plot": "הצגה לוגריטמית", "core-dialogs/rotated-counter-clock": "הוסב ב-45 מעלות בניגוד ללכיוון השעון", "core-dialogs/no-rotation": "ללא סיבוב", - "core-dialogs/rotated-clock": "הוסב ב-45 מעלות עם כיוון השעון", + "core-dialogs/rotated-clock": "הוסב ב־45 מעלות עם כיוון השעון", "core-dialogs/small-dot": "גודל הנקודה הקטנה", "core-dialogs/regular-dot": "גודל נקודה רגילה", "core-dialogs/big-dot": "גודל נקודה גדולה", - "core-dialogs/cell-fields": "בתא הנוכחי, יש כמה שדות: 'value', 'recon' and 'errorMessage'.", - "core-dialogs/cell-value": "ערך התא הנוכחי. זהו קיצור דרך עבור 'cell.value'.", + "core-dialogs/cell-fields": "בתא הנוכחי, יש מספר שדות: ‚value’,‏ ‚recon’ ו־‚errorMessage’.", + "core-dialogs/cell-value": "ערך התא הנוכחי. זהו קיצור דרך עבור ‚cell.value’.", "core-dialogs/row-fields": "בשורה הנוכחית, יש 5 שדות: 'flagged', 'starred', 'index', 'cells', 'record'.", "core-dialogs/cells-of-row": "התאים של השורה הנוכחית. זהו קיצור דרך ל־‚row.cells’. אפשר להחזיר תא מסוים בעזרת ‚cells.‎’ אם היא מילה בודדת, או עם ‚cells[\"\"]’ אם יותר.", "core-dialogs/row-index": "אינדקס השורה הנוכחית. זהו קיצור דרך ל- 'row.index'.", @@ -697,5 +697,6 @@ "core-views/blank-records": "רשומות ריקות לפי עמודה", "core-views/blank-values": "ערכים ריקים לפי עמודה", "core-views/blank-rows": "שורות ריקות", - "core-views/goto-page": "$1 מתוך {{plural:$2|דף אחד|$2 דפים}}" + "core-views/goto-page": "$1 מתוך {{plural:$2|דף אחד|$2 דפים}}", + "core-dialogs/focusing-on-column": " (התמקדות על $1)" } From c6b6616f0157123ae6f6765ad671dc257a6148af Mon Sep 17 00:00:00 2001 From: Cameron Bedard Date: Sat, 2 Jan 2021 03:46:24 -0500 Subject: [PATCH 17/20] Added confirm dialog for starred expression remove links #501 (#3436) * Updated 'remove' link for starred expressions to include a confirm dialog * bring remove expression dialog dismissal inline with other dialog dismissal * Changed 'unstar expression?' in translation-en.json to allow better translation. * Update main/webapp/modules/core/langs/translation-en.json Co-authored-by: Antonin Delpeuch Co-authored-by: Antonin Delpeuch --- .../modules/core/langs/translation-en.json | 1 + .../dialogs/expression-preview-dialog.js | 33 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/main/webapp/modules/core/langs/translation-en.json b/main/webapp/modules/core/langs/translation-en.json index 2b4af6915..9567d2efe 100644 --- a/main/webapp/modules/core/langs/translation-en.json +++ b/main/webapp/modules/core/langs/translation-en.json @@ -244,6 +244,7 @@ "core-dialogs/sql-exporter": "SQL Exporter", "core-dialogs/custom-tab-exp": "Custom Tabular Exporter", "core-dialogs/select-columns-dialog": "Select columns", + "core-dialogs/unstar-expression": "Unstar expression?", "core-dialogs/content": "Content", "core-dialogs/download": "Download", "core-dialogs/upload": "Upload", diff --git a/main/webapp/modules/core/scripts/dialogs/expression-preview-dialog.js b/main/webapp/modules/core/scripts/dialogs/expression-preview-dialog.js index e88f16c9c..7618ef0f7 100644 --- a/main/webapp/modules/core/scripts/dialogs/expression-preview-dialog.js +++ b/main/webapp/modules/core/scripts/dialogs/expression-preview-dialog.js @@ -350,15 +350,30 @@ ExpressionPreviewDialog.Widget.prototype._renderStarredExpressions = function(da var o = Scripting.parse(entry.code); $(''+$.i18n('core-dialogs/remove')+'').appendTo(tr.insertCell(0)).click(function() { - Refine.postCSRF( - "command/core/toggle-starred-expression", - { expression: entry.code, returnList: true }, - function(data) { - self._renderStarredExpressions(data); - self._renderExpressionHistoryTab(); - }, - "json" - ); + var removeExpression = DialogSystem.createDialog(); + removeExpression.width("250px"); + var removeExpressionHead = $('
').addClass("dialog-header").text($.i18n('core-dialogs/unstar-expression')) + .appendTo(removeExpression); + var removeExpressionFooter = $('
').addClass("dialog-footer").appendTo(removeExpression); + + $('').html($.i18n('core-buttons/ok')).click(function() { + Refine.postCSRF( + "command/core/toggle-starred-expression", + { expression: entry.code, returnList: true }, + function(data) { + self._renderStarredExpressions(data); + self._renderExpressionHistoryTab(); + }, + "json" + ); + DialogSystem.dismissUntil(DialogSystem._layers.length - 1); + }).appendTo(removeExpressionFooter); + + $('').text($.i18n('core-buttons/cancel')).click(function() { + DialogSystem.dismissUntil(DialogSystem._layers.length - 1); + }).appendTo(removeExpressionFooter); + + this._level = DialogSystem.showDialog(removeExpression); }); $('Reuse').appendTo(tr.insertCell(1)).click(function() { From 87921e7c95f1e343f731c300ba1e08b61ee46f31 Mon Sep 17 00:00:00 2001 From: Isao Matsunami Date: Fri, 1 Jan 2021 14:52:59 +0000 Subject: [PATCH 18/20] Translated using Weblate (Japanese) Currently translated at 100.0% (769 of 769 strings) Translation: OpenRefine/Translations Translate-URL: https://hosted.weblate.org/projects/openrefine/translations/ja/ --- main/webapp/modules/core/langs/translation-jp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/webapp/modules/core/langs/translation-jp.json b/main/webapp/modules/core/langs/translation-jp.json index 90601f305..9e113f124 100644 --- a/main/webapp/modules/core/langs/translation-jp.json +++ b/main/webapp/modules/core/langs/translation-jp.json @@ -192,7 +192,7 @@ "core-dialogs/use-this-val": "この値を使う", "core-dialogs/cells-of-row": "現在の行のcells: row.cellsの別名です。特定のセルは、cells.かcells[column name]でアクセスできます.", "core-dialogs/for-include-drop-statement-checkbox": "Drop文を含める", - "core-dialogs/ngram-radius": "ngram半径;", + "core-dialogs/ngram-radius": "半径 ", "core-dialogs/processing": "処理中…", "core-dialogs/row-index": "現在の行のindex(row.indexの別名)", "core-dialogs/ignore-facets": "ファセットやフィルタを無視し、すべての行を出力", From fe123129d227ee7fca14fb2f3f427dc411dd4e5f Mon Sep 17 00:00:00 2001 From: Kush Trivedi <44091822+kushthedude@users.noreply.github.com> Date: Mon, 4 Jan 2021 16:58:48 +0530 Subject: [PATCH 19/20] chore: align test-suite name with npm standards (#3439) * chore: align test-suite name with npm standards Signed-off-by: Kush Trivedi * chore: rename ci to openrefine Signed-off-by: Kush Trivedi * chore: make requested changes Signed-off-by: Kush Trivedi --- .github/workflows/pull_request.yml | 8 ++++---- main/tests/cypress/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2465e386e..9b7958361 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,9 +1,9 @@ -name: Java CI +name: Continuous Integration on: [pull_request_target] jobs: - build: + server_tests: strategy: matrix: java: [ 8, 14 ] @@ -72,11 +72,11 @@ jobs: run: | mvn prepare-package -DskipTests=true mvn jacoco:report coveralls:report -DrepoToken=${{ secrets.COVERALLS_TOKEN }} -DpullRequest=${{ github.event.number }} - cypress_tests: + + ui_tests: strategy: matrix: browser: ['chrome'] - runs-on: ubuntu-latest steps: diff --git a/main/tests/cypress/package.json b/main/tests/cypress/package.json index 2b4d41702..c5e8dc6d9 100644 --- a/main/tests/cypress/package.json +++ b/main/tests/cypress/package.json @@ -1,5 +1,5 @@ { - "name": "OpenRefine-Cypress-Test-Suite", + "name": "openrefine-cypress-test-suite", "version": "1.0.0", "description": "Cypress tests for OpenRefine", "license": "BSD-3-Clause", From eb7cdf2d9ccba84f3e2bdb5202059a2d43e810c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Jan 2021 10:13:07 +0100 Subject: [PATCH 20/20] Bump mockito-core from 3.6.28 to 3.7.0 (#3446) Bumps [mockito-core](https://github.com/mockito/mockito) from 3.6.28 to 3.7.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v3.6.28...v3.7.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/database/pom.xml | 2 +- extensions/gdata/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/database/pom.xml b/extensions/database/pom.xml index 5cababbc5..f30b6160c 100644 --- a/extensions/database/pom.xml +++ b/extensions/database/pom.xml @@ -189,7 +189,7 @@ org.mockito mockito-core - 3.6.28 + 3.7.0 test diff --git a/extensions/gdata/pom.xml b/extensions/gdata/pom.xml index 4fe5b9a5b..7284384d7 100644 --- a/extensions/gdata/pom.xml +++ b/extensions/gdata/pom.xml @@ -151,7 +151,7 @@ org.mockito mockito-core - 3.6.28 + 3.7.0 test