diff --git a/src/app/Http/Controllers/ExportController.php b/src/app/Http/Controllers/ExportController.php index 00f1d25..aa48ad6 100644 --- a/src/app/Http/Controllers/ExportController.php +++ b/src/app/Http/Controllers/ExportController.php @@ -64,7 +64,7 @@ public function show(LanguagePack $languagePack) } return view('languagepack.export', [ - 'completedSteps' => ['lang_info', 'tiles', 'wordlist', 'keyboard', 'syllables', 'resources', 'game_settings', 'games', 'export'], + 'completedSteps' => ['lang_info', 'tiles', 'wordlist', 'keyboard', 'syllables', 'resources', 'game_settings', 'games', 'notes', 'export'], 'languagePack' => $languagePack, 'errors' => $groupedErrors, 'exportWarningMessage' => $exportWarningMessage, diff --git a/src/app/Http/Controllers/NotesController.php b/src/app/Http/Controllers/NotesController.php new file mode 100644 index 0000000..850d20b --- /dev/null +++ b/src/app/Http/Controllers/NotesController.php @@ -0,0 +1,98 @@ +route = 'notes'; + $this->model = new Note(); + $this->fileKeyname = 'note'; + + parent::__construct(); + } + + /** + * Edit the language pack setup. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function edit(LanguagePack $languagePack) + { + $items = Note::where('languagepackid', $languagePack->id) + ->orderBy('id') + ->paginate(config('pagination.default')); + + return view('languagepack.notes', [ + 'completedSteps' => ['lang_info', 'tiles', 'wordlist', 'keyboard', 'syllables', 'resources', 'game_settings', 'games', 'notes'], + 'languagePack' => $languagePack, + 'items' => $items, + 'pagination' => $items->links(), + ]); + } + + public function store(LanguagePack $languagePack, Request $request) + { + $validator = Validator::make($request->all(), [ + 'text' => 'required|string', + ]); + + if ($validator->fails()) { + return Redirect::back()->withErrors($validator)->withInput(); + } + + Note::create([ + 'languagepackid' => $languagePack->id, + 'text' => $request->input('text'), + ]); + + $totalPages = (int) ceil(Note::where('languagepackid', $languagePack->id)->count() / config('pagination.default')); + + return redirect("languagepack/notes/{$languagePack->id}?page={$totalPages}"); + } + + public function update(LanguagePack $languagePack, Request $request) + { + $validator = Validator::make($request->all(), [ + 'items.*.id' => 'required|integer', + 'items.*.text' => 'required|string', + ]); + + if ($validator->fails()) { + return Redirect::back()->withErrors($validator)->withInput(); + } + + foreach ($request->input('items', []) as $item) { + Note::where('id', $item['id']) + ->where('languagepackid', $languagePack->id) + ->update(['text' => $item['text']]); + } + + session()->flash('success', 'Records updated successfully'); + + // return the view directly (not a redirect) so the delete confirmation + // step below can see the submitted 'items' delete checkboxes + $itemCollection = Note::where('languagepackid', $languagePack->id) + ->orderBy('id') + ->paginate(config('pagination.default')); + + return view('languagepack.notes', [ + 'completedSteps' => ['lang_info', 'tiles', 'wordlist', 'keyboard', 'syllables', 'resources', 'game_settings', 'games', 'notes'], + 'languagePack' => $languagePack, + 'items' => $itemCollection, + 'pagination' => $itemCollection->links(), + ]); + } +} diff --git a/src/app/Models/LanguagePack.php b/src/app/Models/LanguagePack.php index b48fb47..1dfb0b2 100644 --- a/src/app/Models/LanguagePack.php +++ b/src/app/Models/LanguagePack.php @@ -90,6 +90,11 @@ public function syllables(): HasMany public function resources(): HasMany { return $this->hasMany(Resource::class, 'languagepackid'); + } + + public function notes(): HasMany + { + return $this->hasMany(Note::class, 'languagepackid'); } public function gameSettings(): HasMany diff --git a/src/app/Models/Note.php b/src/app/Models/Note.php new file mode 100644 index 0000000..dbb3a1d --- /dev/null +++ b/src/app/Models/Note.php @@ -0,0 +1,13 @@ +createSheetTab($spreadsheetId, $sheetName, 0); + $values = [ - ["#", "1"], + ["#", "Note", "CreatedAt", "UpdatedAt"], ]; - - $sheetAndRange = $this->getSheetAndRange($sheetName, $values, count($values)); + + $items = Note::where('languagepackid', $this->languagePack->id) + ->orderBy('id') + ->get(); + $sheetAndRange = $this->getSheetAndRange($sheetName, $values, count($items)); + + $i = 1; + foreach ($items as $item) { + $values[$i] = [ + $i, + $item->text, + $item->created_at?->format('Y-m-d H:i:s'), + $item->updated_at?->format('Y-m-d H:i:s'), + ]; + $i++; + } $this->clearAndAddValuesToSheet($spreadsheetId, $sheetAndRange, $values); } diff --git a/src/app/Services/GenerateZipExportService.php b/src/app/Services/GenerateZipExportService.php index 7fb4bed..49cc94b 100644 --- a/src/app/Services/GenerateZipExportService.php +++ b/src/app/Services/GenerateZipExportService.php @@ -5,6 +5,7 @@ use ZipArchive; use App\Models\Key; use App\Models\Game; +use App\Models\Note; use App\Models\Tile; use App\Models\Word; use App\Models\Resource; @@ -55,6 +56,10 @@ public function handle(): string $tilesFile = $this->generateLanginfoFile($tilesFileName, $zip, $zipFileName); $zip->addFile($tilesFile, "{$zipFileName}/res/raw/{$tilesFileName}"); + $notesFileName = 'aa_notes.txt'; + $notesFile = $this->generateNotesFile($notesFileName); + $zip->addFile($notesFile, "{$zipFileName}/res/raw/{$notesFileName}"); + $tilesFileName = 'aa_gametiles.txt'; $tilesFile = $this->generateTilesFile($tilesFileName, $zip, $zipFileName); $zip->addFile($tilesFile, "{$zipFileName}/res/raw/{$tilesFileName}"); @@ -307,6 +312,27 @@ public function generateKeyboardFile(string $fileName): string return $file; } + public function generateNotesFile(string $fileName): string + { + $notes = Note::where('languagepackid', $this->languagePack->id) + ->orderBy('id') + ->get(); + $fileContent = "#" . self::SEPARATOR . "Note" . self::SEPARATOR . "CreatedAt" . self::SEPARATOR . "UpdatedAt\n"; + + $i = 1; + foreach ($notes as $note) { + $fileContent .= "{$i}" . self::SEPARATOR + . "{$note->text}" . self::SEPARATOR + . $note->created_at?->format('Y-m-d H:i:s') . self::SEPARATOR + . $note->updated_at?->format('Y-m-d H:i:s') . "\n"; + $i++; + } + + $file = "{$this->tempDir}/{$fileName}"; + file_put_contents($file, $fileContent); + + return $file; + } public function generateSyllablesFile(string $syllablesFileName, ZipArchive $zip, string $zipFileName): string { diff --git a/src/app/Services/ImportSheetService.php b/src/app/Services/ImportSheetService.php index 0d53c5a..b2962ee 100644 --- a/src/app/Services/ImportSheetService.php +++ b/src/app/Services/ImportSheetService.php @@ -8,6 +8,7 @@ use App\Models\Game; use App\Models\Key; use App\Models\File; +use App\Models\Note; use App\Models\Tile; use App\Models\Word; use App\Models\Syllable; @@ -55,6 +56,7 @@ public function readAndSaveData(string $spreadSheetId, string $sheetType) } try { + $this->saveNotes('notes'); $this->saveLanginfo('langinfo'); $this->saveTiles('gametiles'); $this->saveWords('wordlist'); @@ -290,6 +292,46 @@ private function saveKeyboard(string $worksheetName) } } + private function saveNotes(string $worksheetName): void + { + $rows = $this->getWorksheetRows($worksheetName); + + $firstRow = true; + $data = []; + $key = 0; + foreach ($rows as $row) { + if ($firstRow) { + $firstRow = false; + continue; + } + + if (!empty($row[1])) { + $data[$key]['languagepackid'] = $this->languagePack->id; + $data[$key]['text'] = $row[1]; + $data[$key]['created_at'] = $this->parseDateOrNow($row[2] ?? null); + $data[$key]['updated_at'] = $this->parseDateOrNow($row[3] ?? null); + $key++; + } + } + + if (!empty($data)) { + Note::insert($data); + } + } + + private function parseDateOrNow(?string $value): string + { + if (empty($value)) { + return now()->format('Y-m-d H:i:s'); + } + + try { + return \Carbon\Carbon::parse($value)->format('Y-m-d H:i:s'); + } catch (Exception) { + return now()->format('Y-m-d H:i:s'); + } + } + private function saveGames(string $worksheetName): void { try { diff --git a/src/database/migrations/2026_09_10_000000_create_notes_table.php b/src/database/migrations/2026_09_10_000000_create_notes_table.php new file mode 100644 index 0000000..486888b --- /dev/null +++ b/src/database/migrations/2026_09_10_000000_create_notes_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('languagepackid')->constrained('language_packs') + ->onDelete('cascade'); + $table->text('text'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notes'); + } +}; diff --git a/src/resources/views/languagepack/games.blade.php b/src/resources/views/languagepack/games.blade.php index ebcfc28..9d4d580 100644 --- a/src/resources/views/languagepack/games.blade.php +++ b/src/resources/views/languagepack/games.blade.php @@ -179,7 +179,7 @@ class="inline-flex items-center gap-2 text-sm {{ $requiredAssetsFilter === 'my_g
{{ __('Back') }} - {{ __('Next') }} + {{ __('Next') }}
diff --git a/src/resources/views/languagepack/notes.blade.php b/src/resources/views/languagepack/notes.blade.php new file mode 100644 index 0000000..e0552b2 --- /dev/null +++ b/src/resources/views/languagepack/notes.blade.php @@ -0,0 +1,142 @@ +@extends('layouts.app') + +@section('content') + +@include('layouts/langpacksteps') + +
+ +

{{ __('Notes') }}

+ +
+
+ @if (session()->has('success')) +
+ {{ session()->get('success') }} +
+ @endif +
+
+ + + @if($items && in_array(1, $deleteValues)) +
+ @csrf + @method('DELETE') +
+
+

{{ __('Are you sure want to delete the following notes?') }}

+ + @foreach ($items as $key => $item) + @if(isset($deleteValues[$key])) + id); ?> +
{{ $item->text }}
+ @endif + @endforeach +
+ + + +
+
+
+
+ @endif + + @if ($errors->any()) +
+
    + keys(); + $errorsUnique = array_unique($errors->all()); + ?> + @foreach ($errorsUnique as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + @method('PATCH') + @if(count($items) > 0) +
+ + + + + + + + + + + + + + + + + + + @foreach($items as $key => $item) + + + + + + + + @endforeach +
{{ __('#') }}{{ __('Note') }}{{ __('Created') }}{{ __('Updated') }} {{ __('Delete') }}
{{ $items->firstItem() + $key }} + + + + {{ $item->created_at->format('Y-m-d H:i') }}{{ $item->updated_at->format('Y-m-d H:i') }} + + +
+
+ +
+ {!! $pagination !!} +
+ +

+ + +

+ @endif + +
+ +
+ @csrf +
+
+ + +
+ +
+ + +
+
+ + + + +
+ +@endsection diff --git a/src/resources/views/layouts/langpacksteps.blade.php b/src/resources/views/layouts/langpacksteps.blade.php index 181f3d5..08eca59 100644 --- a/src/resources/views/layouts/langpacksteps.blade.php +++ b/src/resources/views/layouts/langpacksteps.blade.php @@ -8,6 +8,7 @@ 'resources' => ['label' => __('Resources'), 'attribute' => 'resources', 'route' => 'resources'], 'game_settings' => ['label' => __('Settings'), 'attribute' => 'gameSettings', 'route' => 'game_settings'], 'games' => ['label' => __('Games'), 'attribute' => 'games', 'route' => 'games', 'games'], + 'notes' => ['label' => __('Notes'), 'attribute' => 'notes', 'route' => 'notes'], 'export' => ['label' => __('Export'), 'attribute' => 'keys', 'route' => 'export'], ]; diff --git a/src/routes/web.php b/src/routes/web.php index 97bc54f..40b3d7d 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\WordlistController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\GamesController; +use App\Http\Controllers\NotesController; use App\Http\Controllers\ResourcesController; use App\Http\Controllers\SyllablesController; use App\Http\Controllers\GoogleDriveController; @@ -106,6 +107,11 @@ Route::post('api/games/{game}/move', [GamesController::class, 'swapDoor']); + Route::get('languagepack/notes/{languagePack}', [NotesController::class, 'edit']); + Route::post('languagepack/notes/{languagePack}', [NotesController::class, 'store']); + Route::patch('languagepack/notes/{languagePack}', [NotesController::class, 'update']); + Route::delete('languagepack/notes/{languagePack}', [NotesController::class, 'delete']); + Route::get('languagepack/export/{languagePack}', [ExportController::class, 'show']); Route::post('languagepack/export/{languagePack}', [ExportController::class, 'store']); diff --git a/src/tests/Feature/NotesControllerTest.php b/src/tests/Feature/NotesControllerTest.php new file mode 100644 index 0000000..cedd909 --- /dev/null +++ b/src/tests/Feature/NotesControllerTest.php @@ -0,0 +1,116 @@ +languagePack = LanguagePack::factory()->create(); + $this->user = $this->languagePack->owner; + } + + /** @test */ + public function it_lists_notes_in_the_order_they_were_added(): void + { + Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'first']); + Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'second']); + + $response = $this->actingAs($this->user)->get("/languagepack/notes/{$this->languagePack->id}"); + + $response->assertStatus(200); + $response->assertSeeInOrder(['first', 'second']); + } + + /** @test */ + public function it_adds_a_note(): void + { + $response = $this->actingAs($this->user) + ->post("/languagepack/notes/{$this->languagePack->id}", ['text' => 'a new note']); + + $response->assertStatus(302); + $this->assertDatabaseHas('notes', [ + 'languagepackid' => $this->languagePack->id, + 'text' => 'a new note', + ]); + } + + /** @test */ + public function it_edits_an_existing_note(): void + { + $note = Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'original']); + + $response = $this->actingAs($this->user) + ->patch("/languagepack/notes/{$this->languagePack->id}", [ + 'items' => [ + ['id' => $note->id, 'text' => 'updated'], + ], + ]); + + $response->assertStatus(200); + $this->assertDatabaseHas('notes', [ + 'id' => $note->id, + 'text' => 'updated', + ]); + } + + /** @test */ + public function it_deletes_a_note(): void + { + $note = Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'to delete']); + + $response = $this->actingAs($this->user) + ->delete("/languagepack/notes/{$this->languagePack->id}", [ + 'deleteIds' => (string) $note->id, + ]); + + $response->assertStatus(302); + $this->assertDatabaseMissing('notes', ['id' => $note->id]); + } + + /** @test */ + public function it_cancels_a_note_deletion(): void + { + $note = Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'kept']); + + $response = $this->actingAs($this->user) + ->delete("/languagepack/notes/{$this->languagePack->id}", [ + 'deleteIds' => (string) $note->id, + 'btnCancel' => 'cancel', + ]); + + $response->assertStatus(302); + $this->assertDatabaseHas('notes', ['id' => $note->id]); + } + + /** @test */ + public function checking_the_delete_box_and_saving_shows_a_confirmation_before_deleting(): void + { + $note = Note::create(['languagepackid' => $this->languagePack->id, 'text' => 'about to delete']); + + $response = $this->actingAs($this->user) + ->patch("/languagepack/notes/{$this->languagePack->id}", [ + 'items' => [ + ['id' => $note->id, 'text' => $note->text, 'delete' => '1'], + ], + ]); + + $response->assertStatus(200); + $response->assertSee('Are you sure want to delete the following notes?'); + $response->assertSee('about to delete'); + $this->assertDatabaseHas('notes', ['id' => $note->id]); + } +} diff --git a/src/tests/Unit/ExportServiceTest.php b/src/tests/Unit/ExportServiceTest.php index 15f3a1a..bcb2e6b 100644 --- a/src/tests/Unit/ExportServiceTest.php +++ b/src/tests/Unit/ExportServiceTest.php @@ -7,6 +7,7 @@ use Tests\TestCase; use App\Models\File; use App\Models\Tile; +use App\Models\Note; use App\Models\Syllable; use Google\Service\Drive; use App\Models\GameSetting; @@ -172,6 +173,36 @@ public function test_space_placeholder_is_converted_to_empty_space_in_keyboard_e $this->assertEquals('4', $columns[1]); } + public function test_notes_are_written_to_the_zip_export_with_defaults_for_missing_timestamps(): void + { + Note::create([ + 'languagepackid' => $this->languagePack->id, + 'text' => 'first note', + ]); + Note::create([ + 'languagepackid' => $this->languagePack->id, + 'text' => 'second note', + ]); + + $exportService = new GenerateZipExportService($this->languagePack); + $filePath = $exportService->generateNotesFile('aa_notes.txt'); + + $content = file_get_contents($filePath); + $lines = explode("\n", trim($content)); + + $this->assertCount(3, $lines); // header + 2 notes + + $firstColumns = explode("\t", $lines[1]); + $this->assertEquals('1', $firstColumns[0]); + $this->assertEquals('first note', $firstColumns[1]); + $this->assertNotEmpty($firstColumns[2]); // created_at defaulted + $this->assertNotEmpty($firstColumns[3]); // updated_at defaulted + + $secondColumns = explode("\t", $lines[2]); + $this->assertEquals('2', $secondColumns[0]); + $this->assertEquals('second note', $secondColumns[1]); + } + public function test_google_services_file_is_uploaded_to_drive_root(): void { Storage::disk('public')->put("languagepacks/{$this->languagePack->id}/res/raw/google_services.json", '{"project_info":{}}'); diff --git a/src/tests/Unit/ImportSheetServiceTest.php b/src/tests/Unit/ImportSheetServiceTest.php index 2190a63..c042931 100644 --- a/src/tests/Unit/ImportSheetServiceTest.php +++ b/src/tests/Unit/ImportSheetServiceTest.php @@ -55,6 +55,57 @@ public function getSheetByName(string $sheetName) ]); } + public function test_save_notes_uses_provided_timestamps_and_defaults_missing_ones(): void + { + $languagePack = LanguagePack::factory()->create(); + $service = new ImportSheetService($languagePack, 'token', 'folder'); + + $sheet = new class([ + ['#', 'Note', 'CreatedAt', 'UpdatedAt'], + ['1', 'first note', '2026-01-01 10:00:00', '2026-01-02 10:00:00'], + ['2', 'second note', '', ''], + ]) { + public function __construct(private array $rows) + { + } + + public function toArray(): array + { + return $this->rows; + } + }; + + $spreadsheet = new class($sheet) { + public function __construct(private $sheet) + { + } + + public function getSheetByName(string $sheetName) + { + return $this->sheet; + } + }; + + $this->setProtectedProperty($service, 'sheetType', 'xlsx'); + $this->setProtectedProperty($service, 'spreadsheet', $spreadsheet); + + $method = new ReflectionMethod(ImportSheetService::class, 'saveNotes'); + $method->setAccessible(true); + $method->invoke($service, 'notes'); + + $this->assertDatabaseHas('notes', [ + 'languagepackid' => $languagePack->id, + 'text' => 'first note', + 'created_at' => '2026-01-01 10:00:00', + 'updated_at' => '2026-01-02 10:00:00', + ]); + + $secondNote = \App\Models\Note::where('text', 'second note')->first(); + $this->assertNotNull($secondNote); + $this->assertNotNull($secondNote->created_at); + $this->assertNotNull($secondNote->updated_at); + } + public function test_save_syllables_converts_space_to_placeholder(): void { $languagePack = LanguagePack::factory()->create();