Compare commits
2 commits
24fa771d39
...
edfa58579b
| Author | SHA1 | Date | |
|---|---|---|---|
| edfa58579b | |||
| b11bcc3f0b |
4 changed files with 230 additions and 15 deletions
103
README.md
Normal file
103
README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Anagram Finder
|
||||
|
||||
[](LICENSE)
|
||||
[](https://forge.lvl0.xyz/anagram-finder/web/releases)
|
||||
|
||||
Type a handful of letters, get every word you can build from them. Self-hosted, no accounts,
|
||||
no tracking — one input and a list of results.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Finds every dictionary word that fits your letters, longest first
|
||||
- 466,000-word English dictionary, bundled — no external lookups
|
||||
- Results are cached, so a repeated search is instant
|
||||
- Shareable result URLs (`/find/eamstoxil`)
|
||||
- Works without JavaScript; the whole thing is one form and one page
|
||||
- Terminal-styled, dark by default, readable on a phone
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Images are published to `forge.lvl0.xyz/anagram-finder/web`. Grab
|
||||
[`compose.yaml`](compose.yaml), set the three required variables, and start it:
|
||||
|
||||
```bash
|
||||
mkdir anagram-finder && cd anagram-finder
|
||||
curl -O https://forge.lvl0.xyz/anagram-finder/web/raw/branch/main/compose.yaml
|
||||
|
||||
cat > .env <<EOF
|
||||
APP_KEY=base64:$(openssl rand -base64 32)
|
||||
APP_URL=http://localhost:8000
|
||||
DB_PASSWORD=$(openssl rand -hex 16)
|
||||
DB_ROOT_PASSWORD=$(openssl rand -hex 16)
|
||||
EOF
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app is then on <http://127.0.0.1:8000>. It binds to localhost only; put a reverse proxy in
|
||||
front of it for anything public-facing.
|
||||
|
||||
`compose.yaml` pins `:latest`. Check [Releases](https://forge.lvl0.xyz/anagram-finder/web/releases)
|
||||
and pin a version tag if you would rather upgrade deliberately.
|
||||
|
||||
### Configuration
|
||||
|
||||
The snippet above generates everything required. In full:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `APP_KEY` | Encryption key, `base64:` prefixed |
|
||||
| `APP_URL` | The address people will actually use |
|
||||
| `DB_PASSWORD` | Database password |
|
||||
| `DB_ROOT_PASSWORD` | MariaDB root password |
|
||||
|
||||
`DB_DATABASE` and `DB_USERNAME` both default to `anagram`. Anything else you might want to
|
||||
override is listed in [`.env.example`](.env.example).
|
||||
|
||||
Migrations run automatically on first boot.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://forge.lvl0.xyz/anagram-finder/web.git
|
||||
cd web
|
||||
nix-shell
|
||||
```
|
||||
|
||||
The shell prints the available commands and offers to start the containers.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `dev-up` | Start the development environment |
|
||||
| `dev-down` | Stop it (`-v` also drops the database volume) |
|
||||
| `dev-rebuild` | Rebuild images and restart |
|
||||
| `dev-shell` | Enter the app container |
|
||||
| `dev-artisan <cmd>` | Run an artisan command |
|
||||
| `dev-composer <cmd>` | Run composer in the container |
|
||||
| `dev-test` | Run the test suite |
|
||||
| `dev-db` | MariaDB client on the dev database |
|
||||
| `dev-logs` / `dev-logs-db` | Follow the app or database log |
|
||||
|
||||
| Service | URL |
|
||||
|---|---|
|
||||
| App | http://localhost:8001 |
|
||||
| Vite | http://localhost:5174 |
|
||||
| MariaDB | localhost:3308 |
|
||||
|
||||
## Related
|
||||
|
||||
The matching logic and the dictionary live in
|
||||
[anagram-finder/core](https://forge.lvl0.xyz/anagram-finder/core), shared with
|
||||
[anagram-finder/tui](https://forge.lvl0.xyz/anagram-finder/tui), a terminal client.
|
||||
Styling comes from [lvl0/ui](https://forge.lvl0.xyz/lvl0/ui).
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome at
|
||||
[Issues](https://forge.lvl0.xyz/anagram-finder/web/issues).
|
||||
|
||||
## License
|
||||
|
||||
Anagram Finder is free software, licensed under the [GNU AGPL-3.0](LICENSE).
|
||||
|
|
@ -3,13 +3,15 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\FindAnagramWordsRequest;
|
||||
use App\Services\AnagramSearch;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\View\View;
|
||||
use Lvl0\AnagramFinder\Core\Matcher;
|
||||
|
||||
class AnagramController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AnagramSearch $search) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
return view('index');
|
||||
|
|
@ -17,16 +19,30 @@ public function index(): View
|
|||
|
||||
public function find(FindAnagramWordsRequest $request): RedirectResponse
|
||||
{
|
||||
$anagram = $request->get('anagram');
|
||||
|
||||
return redirect()->route('results', $anagram);
|
||||
return redirect()->route('results', $request->validated('anagram'));
|
||||
}
|
||||
|
||||
public function results(Request $request)
|
||||
public function results(string $anagram): View|RedirectResponse
|
||||
{
|
||||
$anagram = $request->anagram;
|
||||
$matches = Matcher::findWords($anagram);
|
||||
// The URL segment bypasses the form request, so it gets the same rules.
|
||||
$anagram = strtolower($anagram);
|
||||
|
||||
return view('index', ['anagram' => $anagram, 'matches' => $matches]);
|
||||
$validator = Validator::make(
|
||||
['anagram' => $anagram],
|
||||
(new FindAnagramWordsRequest)->rules(),
|
||||
(new FindAnagramWordsRequest)->messages(),
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect()
|
||||
->route('home')
|
||||
->withErrors($validator)
|
||||
->withInput(['anagram' => $anagram]);
|
||||
}
|
||||
|
||||
return view('index', [
|
||||
'anagram' => $anagram,
|
||||
'matches' => $this->search->find($anagram),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,35 @@
|
|||
|
||||
class FindAnagramWordsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
'anagram' => ['required', 'string', 'alpha:ascii', 'min:2', 'max:24'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'anagram.alpha' => 'Letters only — no digits, spaces or punctuation.',
|
||||
'anagram.max' => 'That is longer than any word in the dictionary.',
|
||||
];
|
||||
}
|
||||
|
||||
// The dictionary is lowercase and Matcher::check compares raw characters,
|
||||
// so an uppercase anagram would silently return nothing.
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'anagram' => strtolower((string) $this->input('anagram', '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
app/Services/AnagramSearch.php
Normal file
84
app/Services/AnagramSearch.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lvl0\AnagramFinder\Core\Matcher;
|
||||
|
||||
/**
|
||||
* Caches anagram lookups. A search scans the whole dictionary — 466k words —
|
||||
* and the result is fixed for a given (letters, minLength, dictionary), so it
|
||||
* only ever needs computing once.
|
||||
*/
|
||||
class AnagramSearch
|
||||
{
|
||||
private const DICTIONARY = __DIR__ . '/../../vendor/anagram-finder/core/assets/words.txt';
|
||||
|
||||
public function find(string $anagram, int $minLength = 3): Collection
|
||||
{
|
||||
$key = $this->key($anagram, $minLength);
|
||||
|
||||
// Cache a plain array: a serialised Collection comes back as
|
||||
// __PHP_Incomplete_Class through the database store.
|
||||
$words = Cache::rememberForever(
|
||||
$key,
|
||||
fn () => Matcher::findWords($this->normalise($anagram), $minLength)
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
|
||||
return new Collection($words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries live forever; the dictionary hash in the key is the invalidation
|
||||
* mechanism. Replacing words.txt makes every old key unreachable rather
|
||||
* than stale, so nothing has to be flushed by hand.
|
||||
*/
|
||||
private function key(string $anagram, int $minLength): string
|
||||
{
|
||||
return sprintf(
|
||||
'anagram:%s:%d:%s',
|
||||
$this->dictionaryHash(),
|
||||
$minLength,
|
||||
$this->normalise($anagram),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted letters, so "eamstoxil" and "latoximse" share one entry — the same
|
||||
* bag of letters is the same query.
|
||||
*/
|
||||
private function normalise(string $anagram): string
|
||||
{
|
||||
$letters = str_split(strtolower($anagram));
|
||||
sort($letters);
|
||||
|
||||
return implode('', $letters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-addressed, not mtime: the dictionary ships inside the image, so
|
||||
* its mtime changes on every rebuild even when the words do not.
|
||||
*
|
||||
* Hashing 4.6MB per request would undo the saving, so the hash is itself
|
||||
* cached under a key made of the cheap stat values that change whenever the
|
||||
* file is replaced.
|
||||
*/
|
||||
private function dictionaryHash(): string
|
||||
{
|
||||
$path = self::DICTIONARY;
|
||||
|
||||
if (! is_file($path)) {
|
||||
return 'nodict';
|
||||
}
|
||||
|
||||
$stat = filemtime($path) . ':' . filesize($path);
|
||||
|
||||
return Cache::rememberForever(
|
||||
"anagram:dict:{$stat}",
|
||||
fn () => hash_file('xxh128', $path),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue