Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
95 lines
2.7 KiB
PHP
95 lines
2.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\DataTransferObjects;
|
|
|
|
use App\Enums\Media\Source;
|
|
|
|
class MediaItem
|
|
{
|
|
/**
|
|
* @param array<string, mixed>|null $source_meta
|
|
*/
|
|
public function __construct(
|
|
public readonly string $id,
|
|
public readonly string $path,
|
|
public readonly string $url,
|
|
public readonly ?string $mime_type = null,
|
|
public readonly ?string $original_filename = null,
|
|
public readonly ?Source $source = null,
|
|
public readonly ?array $source_meta = null,
|
|
) {}
|
|
|
|
public function isVideo(): bool
|
|
{
|
|
if ($this->mime_type) {
|
|
return str_starts_with($this->mime_type, 'video/');
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
|
|
|
|
return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']);
|
|
}
|
|
|
|
public function isImage(): bool
|
|
{
|
|
if ($this->mime_type) {
|
|
return str_starts_with($this->mime_type, 'image/');
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
|
|
|
|
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
|
|
}
|
|
|
|
public function isDocument(): bool
|
|
{
|
|
if ($this->mime_type) {
|
|
return $this->mime_type === 'application/pdf';
|
|
}
|
|
|
|
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function fromArray(array $data): self
|
|
{
|
|
$path = data_get($data, 'path', '');
|
|
$mimeType = data_get($data, 'mime_type');
|
|
|
|
if (! $mimeType && $path) {
|
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
|
$mimeType = match ($extension) {
|
|
'jpg', 'jpeg' => 'image/jpeg',
|
|
'png' => 'image/png',
|
|
'gif' => 'image/gif',
|
|
'webp' => 'image/webp',
|
|
'mp4' => 'video/mp4',
|
|
'mov' => 'video/quicktime',
|
|
'pdf' => 'application/pdf',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
$sourceValue = data_get($data, 'source');
|
|
$source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null;
|
|
|
|
$sourceMeta = data_get($data, 'source_meta');
|
|
|
|
return new self(
|
|
id: data_get($data, 'id', ''),
|
|
path: $path,
|
|
url: data_get($data, 'url', ''),
|
|
mime_type: $mimeType,
|
|
original_filename: data_get($data, 'original_filename'),
|
|
source: $source,
|
|
source_meta: is_array($sourceMeta) ? $sourceMeta : null,
|
|
);
|
|
}
|
|
}
|