From 2724e8249f6f323ea8ea13bbdff40deffdd8132e Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Wed, 30 Apr 2025 16:48:06 -0600 Subject: [PATCH 1/4] Added tag frequency model and handler --- app/Events/TagFrequencyChanged.php | 33 ++++++++++ .../ComputerScienceResourceController.php | 4 ++ .../Controllers/ResourceEditsController.php | 9 ++- .../Controllers/TagFrequencyController.php | 10 +++ app/Listeners/ModifyTagFrequency.php | 64 +++++++++++++++++++ app/Models/ComputerScienceResource.php | 6 ++ app/Models/TagFrequency.php | 10 +++ app/Services/ResourceEditsService.php | 12 ++-- ...30_202718_create_tag_frequencies_table.php | 28 ++++++++ tests/Unit/ResourceEditsServiceTest.php | 8 +-- 10 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 app/Events/TagFrequencyChanged.php create mode 100644 app/Http/Controllers/TagFrequencyController.php create mode 100644 app/Listeners/ModifyTagFrequency.php create mode 100644 app/Models/TagFrequency.php create mode 100644 database/migrations/2025_04_30_202718_create_tag_frequencies_table.php diff --git a/app/Events/TagFrequencyChanged.php b/app/Events/TagFrequencyChanged.php new file mode 100644 index 00000000..2c9c3868 --- /dev/null +++ b/app/Events/TagFrequencyChanged.php @@ -0,0 +1,33 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index 93566ac6..55bb88fd 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Events\TagFrequencyChanged; use App\Http\Requests\ComputerScienceResource\StoreResourceRequest; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; @@ -77,6 +78,9 @@ public function store(StoreResourceRequest $request) $resource->general_tags = $validatedData['general_tags']; } + // Change tag frequency + TagFrequencyChanged::dispatch(null, $resource->tagCounter()); + Log::debug("Created resource " . json_encode($resource)); return redirect(route('resources.show', ['computerScienceResource' => $resource->id])) diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index ffa28f53..23dbde7b 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Events\TagFrequencyChanged; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Services\ResourceEditsService; @@ -26,7 +27,7 @@ public function create(ComputerScienceResource $computerScienceResource) } - // TODO: Make an array facade + // TODO: Make an array facade or something function normalize($array) { ksort($array); foreach ($array as &$value) { @@ -93,6 +94,7 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc } $resource = ComputerScienceResource::findOrFail($resourceEdits->computer_science_resource_id); + $old_tag_counter = $resource->tagCounter(); $resource->name = $resourceEdits->name; $resource->description = $resourceEdits->description; @@ -108,6 +110,11 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc $resource->programming_language_tags = $resourceEdits->programming_language_tags; $resource->general_tags = $resourceEdits->general_tags; + // Get the new tag counter + $new_tags = collect([$resourceEdits->topic_tags, $resourceEdits->programming_language_tags, $resourceEdits->general_tags])->flatten()->countBy()->toArray(); + // Change tag frequency + TagFrequencyChanged::dispatch($old_tag_counter, $new_tags); + // Delete the edit since we successfully merged the changes $resourceEdits->delete(); diff --git a/app/Http/Controllers/TagFrequencyController.php b/app/Http/Controllers/TagFrequencyController.php new file mode 100644 index 00000000..519ba11a --- /dev/null +++ b/app/Http/Controllers/TagFrequencyController.php @@ -0,0 +1,10 @@ +oldTags ?? []; + $new = $event->newTags ?? []; + + // 1. Build diffs for every tag + $diffs = []; + foreach (array_unique(array_merge(array_keys($old), array_keys($new))) as $tag) { + $diff = ($new[$tag] ?? 0) - ($old[$tag] ?? 0); + if ($diff !== 0) { + $diffs[$tag] = $diff; + } + } + + if (empty($diffs)) { + return; + } + + // 2. One upsert: increment (or decrement) existing, insert new + $upserts = []; + foreach ($diffs as $tag => $count) { + $upserts[] = [ + 'tag' => $tag, + 'count' => $count, + ]; + } + + DB::table('tag_frequencies')->upsert( + $upserts, + ['tag'], + [ + 'count' => DB::raw('tag_frequencies.count + VALUES(count)'), + ] + ); + + // 3. Clean out any zero-or-negative counts + DB::table('tag_frequencies') + ->where('count', '<=', 0) + ->delete(); + } +} diff --git a/app/Models/ComputerScienceResource.php b/app/Models/ComputerScienceResource.php index c75dc95e..ce36c33b 100644 --- a/app/Models/ComputerScienceResource.php +++ b/app/Models/ComputerScienceResource.php @@ -104,4 +104,10 @@ protected function generalTags(): Attribute set: fn(array $value) => $this->syncTagsWithType($value, 'general_tags') ); } + + public function tagCounter(): array + { + $tag_collection = collect([$this->topic_tags, $this->programming_language_tags, $this->general_tags]); + return $tag_collection->flatten()->countBy()->toArray(); + } } diff --git a/app/Models/TagFrequency.php b/app/Models/TagFrequency.php new file mode 100644 index 00000000..d25a312b --- /dev/null +++ b/app/Models/TagFrequency.php @@ -0,0 +1,10 @@ +isLocal()) { + return true; + } + $totalVotes = $edits->resource->votes_count; $neededApprovals = $this->requiredVotes($totalVotes); diff --git a/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php b/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php new file mode 100644 index 00000000..83e505ba --- /dev/null +++ b/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php @@ -0,0 +1,28 @@ +char('tag', 100)->primary(); + $table->bigInteger('count')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tag_frequencies'); + } +}; diff --git a/tests/Unit/ResourceEditsServiceTest.php b/tests/Unit/ResourceEditsServiceTest.php index d3963679..1c69bc3d 100644 --- a/tests/Unit/ResourceEditsServiceTest.php +++ b/tests/Unit/ResourceEditsServiceTest.php @@ -17,19 +17,19 @@ protected function setUp(): void } /** - * Zero votes on a resource, so 1 approval is enough to merge + * Zero votes on a resource, 3 approval is minimum to merge */ public function test_zero_votes_on_edit(): void { - $this->assertEquals($this->service->requiredVotes(0), 1); + $this->assertEquals($this->service->requiredVotes(0), 3); } /** - * 1 vote on a resource, so 1 approval is enough to merge + * 1 vote on a resource, 3 approvals is minimum to merge */ public function test_one_vote_on_edit(): void { - $this->assertEquals($this->service->requiredVotes(1), 1); + $this->assertEquals($this->service->requiredVotes(1), 3); } From 3eb83433126df6d30f2967cce655edf3bd573e9b Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Wed, 30 Apr 2025 19:49:07 -0600 Subject: [PATCH 2/4] Tags can now be searched --- .../Controllers/ResourceEditsController.php | 2 +- .../Controllers/TagFrequencyController.php | 18 ++++- app/Models/ComputerScienceResource.php | 2 +- .../ComputerScienceResourceFactory.php | 3 + ...30_202718_create_tag_frequencies_table.php | 1 - resources/js/Components/Form/TagSelector.vue | 67 ++++++++++++------- routes/web.php | 6 ++ 7 files changed, 69 insertions(+), 30 deletions(-) diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 23dbde7b..5ec1bda4 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -111,7 +111,7 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc $resource->general_tags = $resourceEdits->general_tags; // Get the new tag counter - $new_tags = collect([$resourceEdits->topic_tags, $resourceEdits->programming_language_tags, $resourceEdits->general_tags])->flatten()->countBy()->toArray(); + $new_tags = collect([$resourceEdits->topic_tags, $resourceEdits->programming_language_tags, $resourceEdits->general_tags])->flatten()->unique()->countBy()->toArray(); // Change tag frequency TagFrequencyChanged::dispatch($old_tag_counter, $new_tags); diff --git a/app/Http/Controllers/TagFrequencyController.php b/app/Http/Controllers/TagFrequencyController.php index 519ba11a..89a13f4a 100644 --- a/app/Http/Controllers/TagFrequencyController.php +++ b/app/Http/Controllers/TagFrequencyController.php @@ -2,9 +2,25 @@ namespace App\Http\Controllers; +use App\Models\TagFrequency; use Illuminate\Http\Request; class TagFrequencyController extends Controller { - // + public function search(string $query = "") + { + if (strlen($query) > 50) + { + return response(422)->json(); + } + + $prefixed_tags = TagFrequency::where('tag', 'like', $query.'%') + ->orderByDesc('count') + ->limit(20) + ->get(); + + return response()->json([ + 'tags' => $prefixed_tags + ]); + } } diff --git a/app/Models/ComputerScienceResource.php b/app/Models/ComputerScienceResource.php index ce36c33b..5c296410 100644 --- a/app/Models/ComputerScienceResource.php +++ b/app/Models/ComputerScienceResource.php @@ -108,6 +108,6 @@ protected function generalTags(): Attribute public function tagCounter(): array { $tag_collection = collect([$this->topic_tags, $this->programming_language_tags, $this->general_tags]); - return $tag_collection->flatten()->countBy()->toArray(); + return $tag_collection->flatten()->unique()->countBy()->toArray(); } } diff --git a/database/factories/ComputerScienceResourceFactory.php b/database/factories/ComputerScienceResourceFactory.php index 85f56495..bab74fd9 100644 --- a/database/factories/ComputerScienceResourceFactory.php +++ b/database/factories/ComputerScienceResourceFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Events\TagFrequencyChanged; use App\Models\ComputerScienceResource; use App\Models\User; use Illuminate\Support\Facades\Log; @@ -51,6 +52,8 @@ public function configure(): Factory $resource->topic_tags = fake()->randomElements($tags, fake()->numberBetween(3, count($tags))); $resource->programming_language_tags = fake()->randomElements($tags); $resource->general_tags = fake()->randomElements($tags); + + TagFrequencyChanged::dispatch(null, $resource->tagCounter()); }); } } diff --git a/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php b/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php index 83e505ba..b4cc7c0d 100644 --- a/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php +++ b/database/migrations/2025_04_30_202718_create_tag_frequencies_table.php @@ -14,7 +14,6 @@ public function up(): void Schema::create('tag_frequencies', function (Blueprint $table) { $table->char('tag', 100)->primary(); $table->bigInteger('count')->default(0); - $table->timestamps(); }); } diff --git a/resources/js/Components/Form/TagSelector.vue b/resources/js/Components/Form/TagSelector.vue index b1087e31..69d1f616 100644 --- a/resources/js/Components/Form/TagSelector.vue +++ b/resources/js/Components/Form/TagSelector.vue @@ -4,38 +4,35 @@ import { Icon } from "@iconify/vue"; import { ref } from "vue"; import AutoComplete from "primevue/autocomplete"; import { defineEmits, defineProps } from "vue"; +import axios from "axios"; const props = defineProps({ initial: { type: Array, required: true, }, - queryUrl: { - type: String, - required: false, - } }); const emit = defineEmits(["changed"]); -const tags = ref(new Set(props.initial)); +const selectedTags = ref(new Set(props.initial)); const searchValue = ref(""); -const allSuggestions = ref(["test1", "test2"]); -const suggestions = ref([]); +const tagResult = ref([]); +const tagCount = ref({}); const emptySearchMessage = ref(""); const addTag = (tag) => { - if (tag && !tags.value.has(tag)) { - tags.value.add(tag); + if (tag && !selectedTags.value.has(tag)) { + selectedTags.value.add(tag); searchValue.value = ""; - emit("changed", Array.from(tags.value)); + emit("changed", Array.from(selectedTags.value)); } }; const removeTag = (tag) => { if (tag) { - tags.value.delete(tag); - emit("changed", Array.from(tags.value)); + selectedTags.value.delete(tag); + emit("changed", Array.from(selectedTags.value)); } }; @@ -45,20 +42,30 @@ const handleSelect = (event) => { const handleKeydown = (event) => { if (event.key === "Enter" && searchValue.value.trim()) { - addTag(searchValue.value.trim()); + addTag(searchValue.value.trim().toLowerCase()); event.preventDefault(); } }; -const filterSuggestions = (event) => { - let query = event.query.toLowerCase(); - suggestions.value = allSuggestions.value.filter((item) => - item.toLowerCase().includes(query) - ); +const filterSuggestions = () => { + let query = searchValue.value.trim().toLowerCase(); - if (suggestions.value.length == 0) { - emptySearchMessage.value = searchValue.value; - } + axios + .get(route("tags.search", { query })) + .then((response) => { + const tags = response.data.tags; + + tagResult.value = tags.map((tagJson) => tagJson.tag); + + tagCount.value = Object.fromEntries( + tags.map((tagJson) => [tagJson.tag, tagJson.count]) + ); + }) + .catch(() => { + console.warn("Cannot query server for tags"); + tagResult.value = []; + tagCount.value = {}; + }); }; @@ -67,21 +74,29 @@ const filterSuggestions = (event) => { -