Merge pull request #1567 from linuxserver/fix/export-import-tags

Include tags in item export and restore them on import
This commit is contained in:
KodeStar
2026-07-08 18:29:18 +01:00
committed by GitHub
5 changed files with 216 additions and 5 deletions
+85 -2
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Item;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
@@ -21,6 +22,7 @@ class ItemRestController extends Controller
public function index(): Collection
{
$columns = [
'id',
'title',
'colour',
'url',
@@ -29,11 +31,27 @@ class ItemRestController extends Controller
'appdescription',
];
return Item::select($columns)
return Item::with('parents')
->select($columns)
->where('deleted_at', null)
->where('type', '0')
->orderBy('order', 'asc')
->get();
->get()
->map(function (Item $item) {
return [
'title' => $item->title,
'colour' => $item->colour,
'url' => $item->url,
'description' => $item->description,
'appid' => $item->appid,
'appdescription' => $item->appdescription,
'tags' => $item->parents
->where('id', '!=', 0)
->pluck('title')
->values()
->all(),
];
});
}
/**
@@ -50,6 +68,16 @@ class ItemRestController extends Controller
*/
public function store(Request $request): object
{
// Imports pass tags as an array of tag titles so they can round-trip
// across instances. Resolve those titles into local tag ids (creating
// any that don't yet exist) before handing off to the shared store
// logic. When no tags are supplied we keep the previous behaviour.
if ($request->has('tags')) {
$request->merge([
'tags' => $this->resolveTags($request->input('tags')),
]);
}
$item = ItemController::storelogic($request);
if ($item) {
@@ -59,6 +87,61 @@ class ItemRestController extends Controller
return (object) ['status' => 'FAILED'];
}
/**
* Resolve an incoming list of tags into tag ids.
*
* Numeric 0 (or "0") maps to the root/default dashboard. Every other entry
* is treated as a tag title: an existing tag with that title is reused, and
* a missing one is created. The lookup keeps the operation idempotent so
* importing many items that share a tag title only ever creates one tag.
*
* @param mixed $tags
* @return array<int, int>
*/
private function resolveTags($tags): array
{
if (! is_array($tags)) {
return [0];
}
$ids = [];
foreach ($tags as $tag) {
if ($tag === 0 || $tag === '0') {
$ids[] = 0;
continue;
}
$title = is_string($tag) ? trim($tag) : $tag;
if ($title === '' || $title === null) {
continue;
}
$existing = Item::where('type', '1')
->where('title', $title)
->first();
if ($existing) {
$ids[] = (int) $existing->id;
continue;
}
$created = Item::create([
'title' => $title,
'type' => '1',
'url' => str_slug($title, '-', 'en_US'),
'user_id' => User::currentUser()->getId(),
]);
$ids[] = (int) $created->id;
}
$ids = array_values(array_unique($ids));
return empty($ids) ? [0] : $ids;
}
/**
* Display the specified resource.
*/
+1 -1
View File
@@ -4446,7 +4446,7 @@ var getCSRFToken = function getCSRFToken() {
var mergeItemWithAppDetails = function mergeItemWithAppDetails(item, appDetails) {
return {
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],
appid: item.appid,
title: item.title,
colour: item.colour,
+1 -1
View File
@@ -60,7 +60,7 @@ const getCSRFToken = () => {
*/
const mergeItemWithAppDetails = (item, appDetails) => ({
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],
appid: item.appid,
title: item.title,
+22 -1
View File
@@ -34,7 +34,28 @@ class ItemExportTest extends TestCase
$response = $this->get('api/item');
$response->assertExactJson([(object)$exampleItem]);
$response->assertExactJson([$exampleItem + ["tags" => []]]);
}
public function test_exports_assigned_tag_titles_excluding_the_root_tag(): void
{
$item = Item::factory()
->create([
'title' => 'Tagged Item',
]);
$tag = Item::factory()
->create([
'type' => 1,
'title' => 'Media',
]);
// Assign both the root/default dashboard (id 0) and the Media tag.
$item->parents()->sync([0, $tag->id]);
$response = $this->get('api/item');
$response->assertJsonCount(1);
$response->assertJsonPath('0.tags', ['Media']);
}
public function test_returns_all_items(): void
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Item;
use App\ItemTag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ItemImportTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<string, mixed>
*/
private function importPayload(array $overrides = []): array
{
return array_merge([
'pinned' => 1,
'appid' => 'null',
'website' => null,
'title' => 'Item A',
'colour' => '#00f',
'url' => 'http://10.0.1.1',
'tags' => [0],
], $overrides);
}
public function test_import_creates_and_assigns_a_tag_from_its_title(): void
{
$this->seed();
$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]));
$response->assertStatus(200);
$response->assertJson(['status' => 'OK']);
$tag = Item::where('type', 1)->where('title', 'Media')->first();
$this->assertNotNull($tag);
$this->assertSame(1, (int) $tag->type);
$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);
$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', $tag->id)->exists()
);
}
public function test_import_reuses_an_existing_tag_for_the_same_title(): void
{
$this->seed();
$this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]))->assertStatus(200);
$this->postJson('api/item', $this->importPayload([
'title' => 'Item B',
'tags' => ['Media'],
]))->assertStatus(200);
$this->assertSame(
1,
Item::where('type', 1)->where('title', 'Media')->count()
);
$tag = Item::where('type', 1)->where('title', 'Media')->first();
$itemA = Item::where('type', 0)->where('title', 'Item A')->first();
$itemB = Item::where('type', 0)->where('title', 'Item B')->first();
$this->assertTrue(
ItemTag::where('item_id', $itemA->id)->where('tag_id', $tag->id)->exists()
);
$this->assertTrue(
ItemTag::where('item_id', $itemB->id)->where('tag_id', $tag->id)->exists()
);
}
public function test_import_with_root_tag_only_creates_no_tags(): void
{
$this->seed();
$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => [0],
]));
$response->assertStatus(200);
// No stray tag items should have been created beyond the seeded
// root/default dashboard tag (id 0).
$this->assertSame(0, Item::where('type', 1)->where('id', '>', 0)->count());
// The item should be assigned to the root/default dashboard (tag id 0).
$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);
$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', 0)->exists()
);
}
}