feat: Implement primitive kanji grid & Anki Unicode notes

This commit is contained in:
Dendy 2025-08-28 08:28:04 +02:00
parent f729734a71
commit d9acca0b54
8 changed files with 295 additions and 0 deletions

View File

@ -1 +1,2 @@
import './styles/app.css'; import './styles/app.css';
import './vendor/bootstrap/dist/css/bootstrap.min.css'

View File

@ -0,0 +1,4 @@
a {
text-decoration: none !important;
color: inherit !important;
}

View File

@ -25,4 +25,8 @@ return [
'@hotwired/turbo' => [ '@hotwired/turbo' => [
'version' => '7.3.0', 'version' => '7.3.0',
], ],
'bootstrap/dist/css/bootstrap.min.css' => [
'version' => '5.3.8',
'type' => 'css',
],
]; ];

View File

@ -0,0 +1,64 @@
<?php
namespace App\Controller;
use App\Entity\UnicodeNote;
use App\Service\AnkiService;
use App\Utils\Number;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/kanji', name: 'app_kanji_')]
class KanjiController extends AbstractController
{
function __construct(
private AnkiService $anki,
) {}
public static function tmpl(string $path): string
{
return "kanji/$path.html.twig";
}
#[Route('/{start}-{end}', name: 'index')]
public function index(
string $start,
string $end,
): Response {
$snKanji = array_keys($this->anki->getKnownSnKanjiCounts());
$slnKanji = array_keys($this->anki->getKnownSlnKanjiCounts());
$unicodeKanji = $this->anki->getUnicodeKanji();
$chars = [];
foreach (range(intval("{$start}0", 16), intval("{$end}f", 16)) as $codepoint) {
$charStr = mb_chr($codepoint, 'UTF-8');
$chars[] = [
'str' => $charStr,
'codepoint' => dechex($codepoint),
'lists' => [
'sn' => in_array($charStr, $snKanji, true),
'sln' => in_array($charStr, $slnKanji, true),
'unicode' => in_array($charStr, $unicodeKanji, true),
],
];
}
return $this->render(self::tmpl('grid'), [
'characters' => $chars,
]);
}
#[Route('/{codepoint}/register', 'register', methods: 'GET')]
public function register(string $codepoint): Response
{
// Properly check it's valid (make a util?)
$char = mb_chr(Number::hexint($codepoint), 'UTF-8');
$note = UnicodeNote::fromCharacter($char);
$this->anki->addNote($note, UnicodeNote::DECK);
return new Response();
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Entity;
use App\Utils\Number;
class UnicodeNote extends Note
{
const string MODEL_NAME = 'Unicode';
const string DECK = 'unicode';
private ?int $codepoint = null;
public static function fromCharacter(string $character): self
{
$note = new UnicodeNote()->setCharacter($character);
$note->model = self::MODEL_NAME;
return $note;
}
// ------------------------------------------------------- Anki-related ---
public static function fromAnki(array $noteInfo): static
{
$note = parent::fromAnki($noteInfo);
$note->setCharacter($note->fields['Character']);
return $note;
}
/** @return array<mixed> */
public function toAnki(): array
{
return array_merge(parent::toAnki(), [
'fields' => [
'Character' => $this->getCharacter(),
'Codepoint' => $this->getHex(),
],
]);
}
// ---------------------------------------------------- Derived methods ---
/** @throws \RuntimeException */
public function setCharacter(string $character): static
{
if (mb_strlen($character) !== 1) throw new \InvalidArgumentException(sprintf(
<<<FMT
Character should be only one multi-byte character long.
Passed '%s' which has a length of %d.
FMT,
$character,
mb_strlen($character),
));
$codepoint = mb_ord($character, 'UTF-8')
?: throw new \RuntimeException(sprintf(
'Tried to assign invalid unicode character "%s".',
$character,
));
return $this->setCodepoint($codepoint);
}
public function getCharacter(): string
{
return mb_chr($this->codepoint, 'UTF-8')
?: throw new \RuntimeException(sprintf(
'Codepoint "%s" is not valid',
$this->getHex(),
));
}
public function getHex(): string
{
return dechex($this->codepoint);
}
public function setHex(string $hex): static
{
return $this->setCodepoint(Number::hexint($hex));
}
public function setCodepoint(int $codepoint): static
{
if (!mb_chr($codepoint, 'UTF-8')) throw new \InvalidArgumentException(sprintf(
'Codepoint "%s" is not valid',
$this->getHex(),
));
$this->codepoint = $codepoint;
$this->fields['Character'] = $this->getCharacter();
$this->fields['Codepoint'] = $this->getHex();
return $this;
}
}

View File

@ -5,6 +5,7 @@ namespace App\Service;
use App\Entity\Note; use App\Entity\Note;
use App\Entity\SentenceListeningNote; use App\Entity\SentenceListeningNote;
use App\Entity\SentenceNote; use App\Entity\SentenceNote;
use App\Entity\UnicodeNote;
use App\Utils\Japanese; use App\Utils\Japanese;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
@ -64,6 +65,12 @@ class AnkiService
return $this->request('findNotes', ['query' => $query]); return $this->request('findNotes', ['query' => $query]);
} }
public function getAllUnicodeNoteIds(): array
{
$query = sprintf('"note:%s"', UnicodeNote::MODEL_NAME);
return $this->request('findNotes', ['query' => $query]);
}
public function getAllSentenceNoteIds(): array public function getAllSentenceNoteIds(): array
{ {
return $this->request( return $this->request(
@ -108,6 +115,7 @@ class AnkiService
private static function parseNoteInfo(array $noteInfo): Note private static function parseNoteInfo(array $noteInfo): Note
{ {
return match ($noteInfo['modelName']) { return match ($noteInfo['modelName']) {
UnicodeNote::MODEL_NAME => UnicodeNote::fromAnki($noteInfo),
SentenceNote::MODEL_NAME => SentenceNote::fromAnki($noteInfo), SentenceNote::MODEL_NAME => SentenceNote::fromAnki($noteInfo),
SentenceListeningNote::MODEL_NAME => SentenceListeningNote::fromAnki($noteInfo), SentenceListeningNote::MODEL_NAME => SentenceListeningNote::fromAnki($noteInfo),
default => throw new \Exception(sprintf( default => throw new \Exception(sprintf(
@ -133,6 +141,19 @@ class AnkiService
$this->request('guiBrowse', ['query' => 'nid:' . $note->getId()]); $this->request('guiBrowse', ['query' => 'nid:' . $note->getId()]);
} }
/** @return list<string> */
public function getUnicodeKanji(): array
{
$ret = [];
foreach ($this->getNotes($this->getAllUnicodeNoteIds()) as $note) {
assert($note instanceof UnicodeNote);
$ret[] = $note->getCharacter();
}
return $ret;
}
/** @return array<string, int> */ /** @return array<string, int> */
public function getKnownSlnKanjiCounts(?string $order = null): array public function getKnownSlnKanjiCounts(?string $order = null): array
{ {

55
src/Utils/Number.php Normal file
View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Utils;
class Number
{
public static function hexint(string $num): int
{
// strip optional 0x/0X prefix
if (str_starts_with($num, '0x') || str_starts_with($num, '0X')) {
$num = substr($num, 2);
}
if ($num === '') {
throw new \InvalidArgumentException('Empty hex string.');
}
// validate all characters are hex digits
if (!ctype_xdigit($num)) {
$validChars = '0123456789ABCDEFabcdef';
$strspn = strspn($num, $validChars);
$bad = $num[$strspn] ?? '?';
throw new \InvalidArgumentException(sprintf(
'Failed to convert to decimal. Found invalid character "%s" at position %d.',
$bad,
$strspn
));
}
// hexdec() can return float for very large numbers — guard if you require int
$value = hexdec($num);
if (!is_finite($value) || $value > PHP_INT_MAX) {
}
$value = hexdec($num);
return is_int($value) ? $value : throw new \OverflowException(sprintf(
'Hex value "%s" too large for int.',
$num,
));
}
public static function tryHexint(string $num): ?int
{
// strip optional prefix
if (str_starts_with($num, '0x') || str_starts_with($num, '0X'))
$num = substr($num, 2);
if ($num === '' or !ctype_xdigit($num)) return null;
$value = hexdec($num);
return is_int($value) ? $value : null;
}
}

View File

@ -0,0 +1,47 @@
{% extends 'base.html.twig' %}
{% block title %}Kanji Index{% endblock %}
{% block body %}
<div class="example-wrapper">
<h1 class="text-center p-2 mb-2">{{ block('title') }}</h1>
<div class="d-flex flex-wrap m-2">
{% for char in characters %}
<div style="width: calc(100% / 16);">
<a
target="_blank"
href="{{ path('app_kanji_register', {
codepoint: char.codepoint
}) }}"
>
<div
class="border text-center rounded"
style="
{% if char.lists.unicode %}
border-color: green !important;
border-width: 3px !important;
{% endif %}
margin: 2px;
overflow: hidden;
"
>
<div class="
border-bottom fs-4 pb-1
{{ char.lists.sln ? 'bg-danger text-white' : '' }}
{{ char.lists.sn ? 'bg-warning text-black' : '' }}
">
{{- char.str -}}
</div>
<div style="
font-size: 0.5em;
padding-bottom: 2px;
">
{{ char.codepoint }}
</div>
</div>
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}