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
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))
+
+ @endif
+
+ @if ($errors->any())
+
+
+ keys();
+ $errorsUnique = array_unique($errors->all());
+ ?>
+ @foreach ($errorsUnique as $error)
+ - {{ $error }}
+ @endforeach
+
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+@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();