feat: Implement primitive kanji grid & Anki Unicode notes
This commit is contained in:
parent
f729734a71
commit
d9acca0b54
|
@ -1 +1,2 @@
|
|||
import './styles/app.css';
|
||||
import './vendor/bootstrap/dist/css/bootstrap.min.css'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
a {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
|
@ -25,4 +25,8 @@ return [
|
|||
'@hotwired/turbo' => [
|
||||
'version' => '7.3.0',
|
||||
],
|
||||
'bootstrap/dist/css/bootstrap.min.css' => [
|
||||
'version' => '5.3.8',
|
||||
'type' => 'css',
|
||||
],
|
||||
];
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ namespace App\Service;
|
|||
use App\Entity\Note;
|
||||
use App\Entity\SentenceListeningNote;
|
||||
use App\Entity\SentenceNote;
|
||||
use App\Entity\UnicodeNote;
|
||||
use App\Utils\Japanese;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
|
@ -64,6 +65,12 @@ class AnkiService
|
|||
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
|
||||
{
|
||||
return $this->request(
|
||||
|
@ -108,6 +115,7 @@ class AnkiService
|
|||
private static function parseNoteInfo(array $noteInfo): Note
|
||||
{
|
||||
return match ($noteInfo['modelName']) {
|
||||
UnicodeNote::MODEL_NAME => UnicodeNote::fromAnki($noteInfo),
|
||||
SentenceNote::MODEL_NAME => SentenceNote::fromAnki($noteInfo),
|
||||
SentenceListeningNote::MODEL_NAME => SentenceListeningNote::fromAnki($noteInfo),
|
||||
default => throw new \Exception(sprintf(
|
||||
|
@ -133,6 +141,19 @@ class AnkiService
|
|||
$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> */
|
||||
public function getKnownSlnKanjiCounts(?string $order = null): array
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 %}
|
Loading…
Reference in New Issue