From fb9af1b216a537b5bd10680ff83900ccb224d1ad Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 8 Jul 2026 14:18:45 +0100 Subject: [PATCH] Include tags in item export and restore them on import The export endpoint (api/item) now emits each item's assigned tag titles, excluding the root/default dashboard tag. On import, those titles are resolved back to local tags - reusing an existing tag or creating a missing one - instead of dropping every imported item onto the default dashboard. Tags round-trip by title so a config can be moved between instances without having to reassign each item to its section by hand. Resolves #1555 --- app/Http/Controllers/ItemRestController.php | 87 +++++++++++++++- public/js/app.js | 2 +- resources/assets/js/itemImport.js | 2 +- tests/Feature/ItemExportTest.php | 23 ++++- tests/Feature/ItemImportTest.php | 107 ++++++++++++++++++++ 5 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/ItemImportTest.php diff --git a/app/Http/Controllers/ItemRestController.php b/app/Http/Controllers/ItemRestController.php index f962e1545..ae68f5f1d 100644 --- a/app/Http/Controllers/ItemRestController.php +++ b/app/Http/Controllers/ItemRestController.php @@ -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 @@ public function __construct() public function index(): Collection { $columns = [ + 'id', 'title', 'colour', 'url', @@ -29,11 +31,27 @@ public function index(): Collection '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 @@ public function create() */ 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 @@ public function store(Request $request): object 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 + */ + 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. */ diff --git a/public/js/app.js b/public/js/app.js index 2e50d801b..81de5fd06 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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, diff --git a/resources/assets/js/itemImport.js b/resources/assets/js/itemImport.js index fecf98287..fdd1ef3e9 100644 --- a/resources/assets/js/itemImport.js +++ b/resources/assets/js/itemImport.js @@ -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, diff --git a/tests/Feature/ItemExportTest.php b/tests/Feature/ItemExportTest.php index 77baf32c2..51b9d0c33 100644 --- a/tests/Feature/ItemExportTest.php +++ b/tests/Feature/ItemExportTest.php @@ -34,7 +34,28 @@ public function test_returns_exactly_the_defined_fields(): void $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 diff --git a/tests/Feature/ItemImportTest.php b/tests/Feature/ItemImportTest.php new file mode 100644 index 000000000..e1370a320 --- /dev/null +++ b/tests/Feature/ItemImportTest.php @@ -0,0 +1,107 @@ + + */ + 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() + ); + } +}