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 './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' => [
|
'@hotwired/turbo' => [
|
||||||
'version' => '7.3.0',
|
'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\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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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