Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ Head to `Tools > SEO Pro > Redirects` to create and manage redirects. Each redir

![Redirects listing](https://raw.githubusercontent.com/statamic/seo-pro/refs/heads/7.x/docs-redirects.png)

#### Importing & Exporting

You can import and export redirects as CSV files. Click the "Import/Export" dropdown on the Redirects listing page to access these options.

**Exporting** will download a CSV file containing all redirects for your authorized sites, with the following columns: `source`, `destination`, `response_code`, `enabled`, and `description`.

**Importing** accepts a CSV file with a header row. The `source` and `destination` columns are required. The `response_code`, `enabled`, and `description` columns are optional — if omitted, new redirects will use the default response code and be enabled by default.

If a redirect already exists with the same source URL (on the selected site), it will be updated rather than duplicated. When updating, only the columns present in the CSV will be changed — omitted columns will retain their existing values.

#### Wildcards

You can use wildcards in your source URLs. Each `*` captures a segment, and you can reference them in the destination with `$1`, `$2`, etc:
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
},
"require": {
"statamic/cms": "^6.10",
"pixelfear/composer-dist-plugin": "^0.1.6"
"pixelfear/composer-dist-plugin": "^0.1.6",
"spatie/simple-excel": "^3.9"
},
"require-dev": {
"orchestra/testbench": "^10.0 || ^11.0",
Expand Down
7 changes: 7 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@
'hits' => 'Hits',
'last_hit_at' => 'Last Hit At',
'visit_url' => 'Visit URL',
'export' => 'Export',
'import' => 'Import',
'import_export' => 'Import/Export',
'import_redirects' => 'Import Redirects',
'import_redirects_instructions' => 'Please upload a CSV file containing the redirects you want to import. The CSV file should have a header row, with the following columns:',
'import_redirects_instructions_2' => 'Unless specified, new redirects will be enabled by default. If a redirect already exists with the same source URL, it will be updated.',
'redirects_imported' => ':created redirect(s) created, :updated redirect(s) updated.',

'rules' => [
'pass' => 'Pass',
Expand Down
113 changes: 113 additions & 0 deletions resources/js/components/redirects/ImportRedirectsModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script setup>
import { Modal, Description, ErrorMessage, Button, PublishContainer, PublishFieldsProvider, PublishFields } from '@statamic/cms/ui';
import { ref, getCurrentInstance } from 'vue';

const instance = getCurrentInstance();
const { $axios } = instance.appContext.config.globalProperties;

const emit = defineEmits(['closed', 'imported']);

const open = ref(true);
const busy = ref(false);
const error = ref(null);
const values = ref({ file: [] });
const meta = ref({
file: {
uploadUrl: cp_url('fieldtypes/files/upload'),
},
});

const fields = [
{
handle: 'file',
type: 'files',
display: __('CSV File'),
max_files: 1,
allowed_extensions: ['csv'],
},
];

const submit = () => {
if (!values.value.file.length) return;

busy.value = true;
error.value = null;

$axios.post(cp_url('seo-pro/redirects/import'), values.value)
.then((response) => {
const { created, updated } = response.data;

Statamic.$toast.success(__('seo-pro::messages.redirects_imported', { created, updated }));
emit('imported');
close();
})
.catch((e) => {
if (e.response?.status === 422) {
error.value = e.response.data.message || e.response.data.errors?.file?.[0];
} else {
error.value = __('Something went wrong');
}
})
.finally(() => {
busy.value = false;
});
};

const close = () => {
open.value = false;
setTimeout(() => emit('closed'), 200);
};
</script>

<template>
<Modal
:title="__('seo-pro::messages.import_redirects')"
:open
:dismissable="!busy"
@dismissed="close"
@update:model-value="close"
>
<Description class="mb-6">
<p>{{ __('seo-pro::messages.import_redirects_instructions') }}</p>
<ul class="list-disc list-inside mt-2">
<li>{{ __('seo-pro::messages.source') }} ({{ __('required') }})</li>
<li>{{ __('seo-pro::messages.destination') }} ({{ __('required') }})</li>
<li>{{ __('seo-pro::messages.response_code') }}</li>
<li>{{ __('seo-pro::messages.enabled') }}</li>
<li>{{ __('seo-pro::messages.description') }}</li>
</ul>
<p class="mt-2">{{ __('seo-pro::messages.import_redirects_instructions_2') }}</p>
</Description>

<PublishContainer
:blueprint="fields"
:meta
:track-dirty-state="false"
v-model="values"
>
<PublishFieldsProvider :fields>
<PublishFields />
</PublishFieldsProvider>
</PublishContainer>

<ErrorMessage v-if="error" :text="error" />

<template #footer>
<div class="flex items-center justify-end space-x-3 pt-3 pb-1">
<Button
variant="ghost"
:disabled="busy"
:text="__('Cancel')"
@click="close"
/>
<Button
type="submit"
variant="primary"
:disabled="busy || !values.file.length"
:text="__('seo-pro::messages.import')"
@click="submit"
/>
</div>
</template>
</Modal>
</template>
24 changes: 21 additions & 3 deletions resources/js/pages/redirects/Index.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup>
import { Head, Link } from '@statamic/cms/inertia';
import { Header, Button, Listing, DropdownItem, DocsCallout } from '@statamic/cms/ui';
import { Header, Button, Listing, DropdownItem, DocsCallout, Dropdown, DropdownMenu } from '@statamic/cms/ui';
import StatusIndicator from "../../components/redirects/StatusIndicator.vue";
import { ref } from 'vue';
import ImportRedirectsModal from "../../components/redirects/ImportRedirectsModal.vue";
import { ref, useTemplateRef } from 'vue';

defineProps({
blueprint: Object,
Expand All @@ -15,6 +16,8 @@ defineProps({
const items = ref(null);
const page = ref(null);
const perPage = ref(null);
const showImportModal = ref(false);
const listing = useTemplateRef('listing');

function requestComplete({ items: newItems, parameters }) {
items.value = newItems;
Expand All @@ -27,6 +30,15 @@ function requestComplete({ items: newItems, parameters }) {
<Head :title="__('seo-pro::messages.redirects')" />

<Header :title="__('seo-pro::messages.redirects')" icon="moved">
<Dropdown>
<template #trigger>
<Button :text="__('seo-pro::messages.import_export')" />
</template>
<DropdownMenu>
<DropdownItem :text="__('seo-pro::messages.export')" icon="download" :href="cp_url('seo-pro/redirects/export')" target="_blank" />
<DropdownItem :text="__('seo-pro::messages.import')" icon="upload" @click="showImportModal = true" />
</DropdownMenu>
</Dropdown>
<Button v-if="canCreate" :href="createUrl" :text="__('seo-pro::messages.create_redirect')" variant="primary" />
</Header>

Expand Down Expand Up @@ -56,7 +68,7 @@ function requestComplete({ items: newItems, parameters }) {
v-if="redirect.deletable"
:ref="`deleter_${redirect.id}`"
:resource="redirect"
@deleted="$refs.listing.refresh()"
@deleted="listing.refresh()"
/>
</template>
<template #cell-status="{ row: redirect }">
Expand All @@ -75,4 +87,10 @@ function requestComplete({ items: newItems, parameters }) {
</Listing>

<DocsCallout :topic="__('seo-pro::messages.redirects')" url="https://statamic.com/addons/statamic/seo-pro/docs" />

<ImportRedirectsModal
v-if="showImportModal"
@closed="showImportModal = false"
@imported="listing.refresh()"
/>
</template>
5 changes: 4 additions & 1 deletion routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
Route::patch('section-defaults/taxonomies/{seo_pro_taxonomy}', [Controllers\CP\TaxonomyDefaultsController::class, 'update'])->name('section-defaults.taxonomies.update');

Route::resource('errors', Controllers\CP\ErrorController::class)->only('index');
Route::resource('redirects', Controllers\CP\RedirectController::class)->except('show');

Route::get('redirects/export', Controllers\CP\Redirects\ExportRedirectsController::class)->name('redirects.export');
Route::post('redirects/import', Controllers\CP\Redirects\ImportRedirectsController::class)->name('redirects.import');
Route::resource('redirects', Controllers\CP\Redirects\RedirectController::class)->except('show');

Route::post('preview', Controllers\CP\PreviewController::class)->name('preview');
});
43 changes: 43 additions & 0 deletions src/Http/Controllers/CP/Redirects/ExportRedirectsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace Statamic\SeoPro\Http\Controllers\CP\Redirects;

use Spatie\SimpleExcel\SimpleExcelWriter;
use Statamic\Facades\Site;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\SeoPro\Facades;
use Statamic\SeoPro\Redirects\Redirect;

class ExportRedirectsController extends CpController
{
public function __invoke()
{
$this->authorize('index', Redirect::class);

$query = Facades\Redirect::query();

if (Site::multiEnabled()) {
$query->whereIn('site', Site::authorized()->map->handle()->all());
}

$path = tempnam(sys_get_temp_dir(), 'redirects-export-').'.csv';

$writer = SimpleExcelWriter::createWithoutBom($path);

$query->get()->each(function ($redirect) use ($writer) {
$writer->addRow([
'source' => $redirect->source(),
'destination' => $redirect->destination(),
'response_code' => $redirect->responseCode(),
'enabled' => $redirect->enabled() ? 'true' : 'false',
'description' => $redirect->get('description'),
]);
});

$writer->close();

return response()->download($path, 'redirects.csv', [
'Content-Type' => 'text/csv',
])->deleteFileAfterSend();
}
}
99 changes: 99 additions & 0 deletions src/Http/Controllers/CP/Redirects/ImportRedirectsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Statamic\SeoPro\Http\Controllers\CP\Redirects;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\SimpleExcel\SimpleExcelReader;
use Statamic\Facades\Site;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\SeoPro\Facades;
use Statamic\SeoPro\Redirects\Redirect;

class ImportRedirectsController extends CpController
{
public function __invoke(Request $request): JsonResponse
{
$this->authorize('create', Redirect::class);

$request->validate([
'file' => ['required', 'array', 'min:1'],
]);

$path = Storage::disk('local')->path("statamic/file-uploads/{$request->file[0]}");

$reader = SimpleExcelReader::create($path);
$headers = collect($reader->getHeaders())->map(fn (string $header): string => (string) Str::of($header)->lower()->snake());
$rows = $reader->useHeaders($headers->all())->getRows();

if (! $headers->contains('source') || ! $headers->contains('destination')) {
return response()->json([
'message' => 'The CSV must have at least two columns: source, destination.',
], 422);
}

$created = 0;
$updated = 0;
$site = Site::selected()->handle();
$defaultResponseCode = config('statamic.seo-pro.redirects.default_response_code', 301);

$rows->each(function (array $row) use ($headers, $site, $defaultResponseCode, &$created, &$updated) {
$source = trim($row['source'] ?? '');
$destination = trim($row['destination'] ?? '');

if (empty($source)) {
return;
}

$existingRedirect = Facades\Redirect::query()
->where('source', $source)
->where('site', $site)
->first();

if ($existingRedirect) {
$existingRedirect->destination($destination);

if ($headers->contains('response_code')) {
$existingRedirect->responseCode((int) $row['response_code'] ?: $defaultResponseCode);
}

if ($headers->contains('enabled')) {
$existingRedirect->enabled(filter_var($row['enabled'], FILTER_VALIDATE_BOOLEAN));
}

if ($headers->contains('description') && $description = trim($row['description'])) {
$existingRedirect->set('description', $description);
}

$existingRedirect->save();
$updated++;
} else {
$enabled = $headers->contains('enabled') ? filter_var($row['enabled'], FILTER_VALIDATE_BOOLEAN) : true;
$responseCode = $headers->contains('response_code') ? ((int) $row['response_code'] ?: $defaultResponseCode) : $defaultResponseCode;

$redirect = Facades\Redirect::make()
->site($site)
->source($source)
->destination($destination)
->responseCode($responseCode)
->enabled($enabled);

if ($headers->contains('description') && $description = trim($row['description'])) {
$redirect->set('description', $description);
}

$redirect->save();
$created++;
}
});

Storage::disk('local')->delete("statamic/file-uploads/{$request->file[0]}");

return response()->json([
'created' => $created,
'updated' => $updated,
]);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace Statamic\SeoPro\Http\Controllers\CP;
namespace Statamic\SeoPro\Http\Controllers\CP\Redirects;

use Illuminate\Http\Request;
use Illuminate\Support\Arr;
Expand Down
Loading
Loading