Skip to content

Commit

Permalink
Merge pull request shlinkio#24 from acelaya-forks/feature/csv-import
Browse files Browse the repository at this point in the history
Feature/csv import
  • Loading branch information
acelaya authored Feb 6, 2021
2 parents b6fc81f + f93619c commit 0f3ace3
Show file tree
Hide file tree
Showing 27 changed files with 505 additions and 47 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]
## [2.2.0] - 2021-02-06
### Added
* [#21](https://github.com/shlinkio/shlink-importer/issues/21) Added support to import URL `title` prop.
* [#5](https://github.com/shlinkio/shlink-importer/issues/5) Added support to import from a standard CSV file.

### Changed
* Migrated build to Github Actions.
* [#23](https://github.com/shlinkio/shlink-importer/issues/23) Increased required MSI to 75%.

### Deprecated
* *Nothing*
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ This module can be installed using composer:

## Supported import sources

* Bit.ly
#### Bit.ly

It imports using the API v4. The only required param is an [access token](https://bitly.is/accesstoken).

#### Standard CSV

It parses a CSV file with the `Long URL` and `Short code` columns. It can optionally contain `Domain`, `Title` and `Tags`, being the latter a pipe-separated list of items (`foo|bar|baz`).

Column names can have spaces and have any combination of upper and lowercase.

## Usage

Expand Down
9 changes: 5 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
"require": {
"php": "^7.4 || ^8.0",
"ext-json": "*",
"laminas/laminas-servicemanager": "^3.4",
"lstrojny/functional-php": "^1.14",
"laminas/laminas-servicemanager": "^3.6.4",
"league/csv": "^9.6",
"lstrojny/functional-php": "^1.15",
"shlinkio/shlink-config": "^1.0",
"symfony/console": "^5.1"
"symfony/console": "^5.2"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
Expand Down Expand Up @@ -57,7 +58,7 @@
"test": "phpdbg -qrr vendor/bin/phpunit --order-by=random --testdox --colors=always",
"test:ci": "@test --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
"test:pretty": "@test --coverage-html build/coverage-html",
"infect": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered",
"infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered",
"infect:ci": "@infect --coverage=build --skip-initial-tests",
"infect:show": "@infect --show-mutations",
"infect:show:ci": "@infect --show-mutations --coverage=build",
Expand Down
14 changes: 9 additions & 5 deletions config/dependencies.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,31 @@
'cli' => [
'importer_strategies' => [
'factories' => [
Strategy\BitlyApiImporter::class => ConfigAbstractFactory::class,
Sources\Bitly\BitlyApiImporter::class => ConfigAbstractFactory::class,
Sources\Csv\CsvImporter::class => InvokableFactory::class,
],

'aliases' => [
Strategy\ImportSources::BITLY => Strategy\BitlyApiImporter::class,
Sources\ImportSources::BITLY => Sources\Bitly\BitlyApiImporter::class,
Sources\ImportSources::CSV => Sources\Csv\CsvImporter::class,
],
],

'params_console_helpers' => [
'factories' => [
Params\ConsoleHelper\BitlyApiParamsConsoleHelper::class => InvokableFactory::class,
Sources\Bitly\BitlyApiParamsConsoleHelper::class => InvokableFactory::class,
Sources\Csv\CsvParamsConsoleHelper::class => InvokableFactory::class,
],

'aliases' => [
Strategy\ImportSources::BITLY => Params\ConsoleHelper\BitlyApiParamsConsoleHelper::class,
Sources\ImportSources::BITLY => Sources\Bitly\BitlyApiParamsConsoleHelper::class,
Sources\ImportSources::CSV => Sources\Csv\CsvParamsConsoleHelper::class,
],
],
],

ConfigAbstractFactory::class => [
Strategy\BitlyApiImporter::class => [ClientInterface::class, RequestFactoryInterface::class],
Sources\Bitly\BitlyApiImporter::class => [ClientInterface::class, RequestFactoryInterface::class],
Command\ImportCommand::class => [
Strategy\ImporterStrategyManager::class,
Params\ConsoleHelper\ConsoleHelperManager::class,
Expand Down
6 changes: 3 additions & 3 deletions src/Command/ImportCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ConsoleHelperManagerInterface;
use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ParamsConsoleHelperInterface;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyInterface;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyManagerInterface;
use Shlinkio\Shlink\Importer\Strategy\ImportSources;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -47,8 +47,8 @@ protected function configure(): void
->setName(self::NAME)
->setDescription('Allows to import short URLs from third party sources')
->addArgument('source', InputArgument::REQUIRED, sprintf(
'The source from which you want to import. Supported sources: ["%s"]',
implode('", "', ImportSources::getAll()),
'The source from which you want to import. Supported sources: [<info>%s</info>]',
implode('</info>, <info>', ImportSources::getAll()),
));
}

Expand Down
2 changes: 1 addition & 1 deletion src/Exception/InvalidSourceException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace Shlinkio\Shlink\Importer\Exception;

use InvalidArgumentException;
use Shlinkio\Shlink\Importer\Strategy\ImportSources;
use Shlinkio\Shlink\Importer\Sources\ImportSources;

use function implode;
use function sprintf;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Exception;
namespace Shlinkio\Shlink\Importer\Sources\Bitly;

use Shlinkio\Shlink\Importer\Exception\ImportException;

use function sprintf;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Strategy;
namespace Shlinkio\Shlink\Importer\Sources\Bitly;

use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Shlinkio\Shlink\Importer\Exception\BitlyApiException;
use Shlinkio\Shlink\Importer\Exception\ImportException;
use Shlinkio\Shlink\Importer\Model\BitlyApiProgressTracker;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiException;
use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiProgressTracker;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyInterface;
use Shlinkio\Shlink\Importer\Util\DateHelpersTrait;
use Throwable;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Params;
namespace Shlinkio\Shlink\Importer\Sources\Bitly;

class BitlyApiParams
final class BitlyApiParams
{
private string $accessToken;
private bool $importTags;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Params\ConsoleHelper;
namespace Shlinkio\Shlink\Importer\Sources\Bitly;

use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ParamsConsoleHelperInterface;
use Symfony\Component\Console\Style\StyleInterface;

class BitlyApiParamsConsoleHelper implements ParamsConsoleHelperInterface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Model;
namespace Shlinkio\Shlink\Importer\Sources\Bitly;

use DateInterval;
use DateTimeImmutable;
use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
use Shlinkio\Shlink\Importer\Util\DateHelpersTrait;

use function base64_decode;
Expand Down
89 changes: 89 additions & 0 deletions src/Sources/Csv/CsvImporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Sources\Csv;

use DateTimeImmutable;
use DateTimeInterface;
use League\Csv\Reader;
use Shlinkio\Shlink\Importer\Exception\ImportException;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyInterface;

use function array_filter;
use function explode;
use function Functional\reduce_left;
use function str_replace;
use function strtolower;
use function trim;

class CsvImporter implements ImporterStrategyInterface
{
private const TAG_SEPARATOR = '|';

private ?DateTimeInterface $date;

public function __construct(?DateTimeInterface $date = null)
{
$this->date = $date;
}

/**
* @return ImportedShlinkUrl[]
* @throws ImportException
*/
public function import(array $rawParams): iterable
{
$params = CsvParams::fromRawParams($rawParams);
$now = $this->date ?? new DateTimeImmutable();

$csvReader = Reader::createFromStream($params->stream())->setDelimiter($params->delimiter())
->setHeaderOffset(0);

foreach ($csvReader as $record) {
$record = $this->remapRecordHeaders($record);

yield new ImportedShlinkUrl(
ImportSources::CSV,
$record['longurl'],
$this->parseTags($record),
$now,
$this->nonEmptyValueOrNull($record, 'domain'),
$record['shortcode'],
$this->nonEmptyValueOrNull($record, 'title'),
);
}
}

private function remapRecordHeaders(array $record): array
{
return reduce_left($record, static function ($value, string $index, array $c, array $acc) {
$normalizedKey = strtolower(str_replace(' ', '', $index));
$acc[$normalizedKey] = $value;

return $acc;
}, []);
}

private function nonEmptyValueOrNull(array $record, string $key): ?string
{
$value = $record[$key] ?? null;
if (empty($value)) {
return null;
}

$trimmedValue = trim($value);
if (empty($trimmedValue)) {
return null;
}

return $trimmedValue;
}

private function parseTags(array $record): array
{
return array_filter(explode(self::TAG_SEPARATOR, $this->nonEmptyValueOrNull($record, 'tags') ?? ''));
}
}
38 changes: 38 additions & 0 deletions src/Sources/Csv/CsvParams.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Sources\Csv;

final class CsvParams
{
/** @var resource */
private $stream;
private string $delimiter;

private function __construct()
{
}

public static function fromRawParams(array $params): self
{
$instance = new self();
$instance->delimiter = $params['delimiter'] ?? '';
$instance->stream = $params['stream'] ?? '';

return $instance;
}

/**
* @return resource
*/
public function stream()
{
return $this->stream;
}

public function delimiter(): string
{
return $this->delimiter;
}
}
42 changes: 42 additions & 0 deletions src/Sources/Csv/CsvParamsConsoleHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Sources\Csv;

use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ParamsConsoleHelperInterface;
use Symfony\Component\Console\Style\StyleInterface;

use function fopen;

class CsvParamsConsoleHelper implements ParamsConsoleHelperInterface
{
public function requestParams(StyleInterface $io): array
{
return [
'import_short_codes' => true,
'stream' => $io->ask('What\'s the path for the CSV file you want to import', null, [$this, 'pathToStream']),
'delimiter' => $io->choice('What\'s the delimiter used to separate values?', [
',' => 'Comma',
';' => 'Semicolon',
], ','),
];
}

/**
* @return resource
*/
public function pathToStream(?string $value)
{
if (empty($value)) {
throw InvalidPathException::pathNotProvided();
}

$file = @fopen($value, 'rb');
if (! $file) {
throw InvalidPathException::pathIsNotFile($value);
}

return $file;
}
}
23 changes: 23 additions & 0 deletions src/Sources/Csv/InvalidPathException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Importer\Sources\Csv;

use RuntimeException;
use Shlinkio\Shlink\Importer\Exception\ExceptionInterface;

use function sprintf;

class InvalidPathException extends RuntimeException implements ExceptionInterface
{
public static function pathNotProvided(): self
{
return new self('The path of the file is required.');
}

public static function pathIsNotFile(string $path): self
{
return new self(sprintf('The file "%s" does not seem to exist. Try another one.', $path));
}
}
Loading

0 comments on commit 0f3ace3

Please sign in to comment.