Compare commits
10 Commits
0ac5d035a2
...
3ac2cd7319
Author | SHA1 | Date |
---|---|---|
|
3ac2cd7319 | |
|
9093b1a0f8 | |
|
605d65e468 | |
|
7333c5164d | |
|
96963fb926 | |
|
eadd8a01ea | |
|
8c1613187a | |
|
cbaac88644 | |
|
6b184ca014 | |
|
64d5381d1e |
|
@ -1,10 +1 @@
|
||||||
import './bootstrap.js';
|
|
||||||
/*
|
|
||||||
* Welcome to your app's main JavaScript file!
|
|
||||||
*
|
|
||||||
* This file will be included onto the page via the importmap() Twig function,
|
|
||||||
* which should already be in your base.html.twig.
|
|
||||||
*/
|
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
|
||||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
body {
|
|
||||||
background-color: skyblue;
|
|
||||||
}
|
|
|
@ -0,0 +1,242 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\SentenceListeningNote;
|
||||||
|
use App\Entity\SentenceNote;
|
||||||
|
use App\Entity\Term;
|
||||||
|
use App\Service\AnkiService;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:create-production',
|
||||||
|
description: 'Add a short description for your command',
|
||||||
|
)]
|
||||||
|
class CreateProductionCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private AnkiService $ankiService,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractKanji(string $str): array
|
||||||
|
{
|
||||||
|
preg_match_all('/\p{Script=Han}/u', $str, $matches);
|
||||||
|
return array_unique($matches[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getOnlyKanji(string $str): string
|
||||||
|
{
|
||||||
|
return preg_replace('/[^\p{Script=Han}]/u', '', $str);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function kanjiDiff(array &$ref, string $subject): bool
|
||||||
|
{
|
||||||
|
$len = mb_strlen($subject);
|
||||||
|
$hasUnseenKanji = false;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$subKanji = mb_substr($subject, $i, 1);
|
||||||
|
|
||||||
|
foreach ($ref as $refKanji => $value) {
|
||||||
|
if ($subKanji === $refKanji) continue 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ref[$subKanji] = 0;
|
||||||
|
$hasUnseenKanji = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hasUnseenKanji;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
//$this
|
||||||
|
// ->addArgument('arg1', InputArgument::OPTIONAL, 'Argument description')
|
||||||
|
// ->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
|
||||||
|
//;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
printf('Getting all SentenceCards...');
|
||||||
|
$allIds = $this->ankiService->getAllSentenceNoteIds();
|
||||||
|
$allNotes = $this->ankiService->getNotes($allIds);
|
||||||
|
printf(" OK (%d)\n", count($allNotes));
|
||||||
|
|
||||||
|
printf('Getting all SentenceCards...');
|
||||||
|
$allListeningIds = $this->ankiService->getAllSentenceListeningNoteIds();
|
||||||
|
$allListeningNotes = $this->ankiService->getNotes($allListeningIds);
|
||||||
|
printf(" OK (%d)\n", count($allListeningNotes));
|
||||||
|
|
||||||
|
printf('Indexing all terms...');
|
||||||
|
$knownTerms = [];
|
||||||
|
$knownKanji = [];
|
||||||
|
$termCounts = [];
|
||||||
|
foreach ($allNotes as $note) {
|
||||||
|
if (!$note instanceof SentenceNote) throw new \Exception(sprintf(
|
||||||
|
'Expected SentenceNote, got %s',
|
||||||
|
$note::class,
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($note->getTerms() as &$term) {
|
||||||
|
assert($term instanceof Term);
|
||||||
|
|
||||||
|
if (key_exists($term->getKanji(), $knownTerms)) continue;
|
||||||
|
$termCounts[$term->getKanji()] = 0;
|
||||||
|
$knownTerms[$term->getKanji()] = &$term;
|
||||||
|
foreach (self::extractKanji($term->getKanji()) as $kanji) {
|
||||||
|
$knownKanji[$kanji] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf(" OK (%d)\n", count($knownTerms));
|
||||||
|
|
||||||
|
$total = count($knownTerms);
|
||||||
|
$i = 0;
|
||||||
|
foreach ($allNotes as $note) {
|
||||||
|
$i += 1;
|
||||||
|
if ($i % 12 === 0 or $i === $total) {
|
||||||
|
printf(
|
||||||
|
"\33[2K\r% 7d/% 7d | %.2f GiB | Getting frequencies",
|
||||||
|
$i,
|
||||||
|
$total,
|
||||||
|
memory_get_usage() / 1024 / 1024 / 1024
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert($note instanceof SentenceNote);
|
||||||
|
|
||||||
|
$sentKanji = str_replace(
|
||||||
|
"\u{200E}",
|
||||||
|
'',
|
||||||
|
strip_tags($note->getFields()['SentKanji'])
|
||||||
|
);
|
||||||
|
|
||||||
|
//foreach ($knownTerms as &$term) {
|
||||||
|
// assert($term instanceof Term);
|
||||||
|
|
||||||
|
// if (str_contains($sentKanji, $term->getKanji())) {
|
||||||
|
// $termCounts[$term->getKanji()] += 1;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
foreach ($knownKanji as $kanji => &$count) {
|
||||||
|
if (str_contains($sentKanji, $kanji)) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf("\n");
|
||||||
|
|
||||||
|
$seenKanji = [];
|
||||||
|
//uksort($knownTerms, function ($a, $b) {
|
||||||
|
// //return strlen(self::getOnlyKanji($b)) <=> strlen(self::getOnlyKanji($a)); // descending order
|
||||||
|
// return strlen($b) <=> strlen($a); // ascending order
|
||||||
|
//});
|
||||||
|
|
||||||
|
|
||||||
|
printf('Rating terms...');
|
||||||
|
foreach ($knownTerms as $term) {
|
||||||
|
$termKanji = self::getOnlyKanji($term->getKanji());
|
||||||
|
$weight = 1 / max(mb_strlen($termKanji), 1);
|
||||||
|
|
||||||
|
// First pass: Calculate the weight
|
||||||
|
foreach ($knownKanji as $kanji => $count) {
|
||||||
|
if (str_contains($termKanji, $kanji)) {
|
||||||
|
$termCounts[$term->getKanji()] += ceil($count * $weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arsort($termCounts);
|
||||||
|
|
||||||
|
// Have into account the ones that have already been created.
|
||||||
|
// This will not only skip them but take into account the kanjis they
|
||||||
|
// have.
|
||||||
|
foreach ($allListeningNotes as $listeningNote) {
|
||||||
|
assert($listeningNote instanceof SentenceListeningNote);
|
||||||
|
|
||||||
|
$termKanji = self::getOnlyKanji($listeningNote->getTerm()->getKanji());
|
||||||
|
self::kanjiDiff($seenKanji, $termKanji);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($termCounts as $term => $count) {
|
||||||
|
$termKanji = self::getOnlyKanji($term);
|
||||||
|
|
||||||
|
// Second pass: Penalize terms with no new kanji at all
|
||||||
|
if (!self::kanjiDiff($seenKanji, $termKanji)) {
|
||||||
|
unset($termCounts[$term]);
|
||||||
|
//unset($knownTerms[$term->getKanji()]);
|
||||||
|
//$termCounts[$term->getKanji()] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf(" OK\n");
|
||||||
|
|
||||||
|
$theChosenTerm = null;
|
||||||
|
arsort($termCounts);
|
||||||
|
printf("\n");
|
||||||
|
foreach ($termCounts as $term => $count) {
|
||||||
|
$theChosenTerm = $knownTerms[$term];
|
||||||
|
$termKanji = self::getOnlyKanji($term);
|
||||||
|
printf("%s: %d\n", $term, $count);
|
||||||
|
|
||||||
|
$len = mb_strlen($termKanji);
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$iKanji = mb_substr($termKanji, $i, 1);
|
||||||
|
printf(" - %s: %0.2f\n", $iKanji, $knownKanji[$iKanji] / $len);
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\n");
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
$noteIds = $this->ankiService->findNotesIds(sprintf(
|
||||||
|
//'VocabKanji:*%s* "note:%s"',
|
||||||
|
'*%s* "note:%s"',
|
||||||
|
$theChosenTerm->getKanji(),
|
||||||
|
SentenceNote::MODEL_NAME,
|
||||||
|
));
|
||||||
|
$theChosenNote = $this->ankiService->getNote($noteIds[array_key_last($noteIds)]);
|
||||||
|
|
||||||
|
$newSlNote = SentenceListeningNote::fromNote($theChosenNote, $theChosenTerm);
|
||||||
|
if (!$this->ankiService->addNote($newSlNote, 'production')) {
|
||||||
|
throw new \Exception('Failed to add note!');
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
<<<FMNT
|
||||||
|
total: %d cards
|
||||||
|
max usage: %0.2f MiB
|
||||||
|
current usage: %0.2f MiB\n
|
||||||
|
FMNT,
|
||||||
|
count($termCounts),
|
||||||
|
memory_get_peak_usage() / 1024 / 1024,
|
||||||
|
memory_get_usage() / 1024 / 1024,
|
||||||
|
);
|
||||||
|
|
||||||
|
//dd($kanjiNotes);
|
||||||
|
|
||||||
|
//$io = new SymfonyStyle($input, $output);
|
||||||
|
//$arg1 = $input->getArgument('arg1');
|
||||||
|
|
||||||
|
//if ($arg1) {
|
||||||
|
// $io->note(sprintf('You passed an argument: %s', $arg1));
|
||||||
|
//}
|
||||||
|
|
||||||
|
//if ($input->getOption('option1')) {
|
||||||
|
// // ...
|
||||||
|
//}
|
||||||
|
|
||||||
|
//$io->success('You have a new command! Now make it your own! Pass --help to see your options.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Note;
|
||||||
|
use App\Entity\SentenceNote;
|
||||||
|
use App\Entity\Term;
|
||||||
use App\Service\AnkiService;
|
use App\Service\AnkiService;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
@ -14,24 +17,67 @@ class AnkiController extends AbstractController
|
||||||
private AnkiService $ankiService,
|
private AnkiService $ankiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public static function tmpl(string $path): string
|
||||||
|
{
|
||||||
|
return "anki/$path.html.twig";
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/', name: 'main')]
|
#[Route('/', name: 'main')]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$note = $this->ankiService->getLatestNote();
|
$allIds = $this->ankiService->getAllSentenceNoteIds();
|
||||||
|
$allNotes = $this->ankiService->getNotes($allIds);
|
||||||
|
|
||||||
$this->ankiService->updateNote($note);
|
$kanjiNotes = [];
|
||||||
|
foreach ($allNotes as $note) {
|
||||||
|
if (!$note instanceof SentenceNote) throw new \Exception(sprintf(
|
||||||
|
'Expected SentenceNote, got %s',
|
||||||
|
$note::class,
|
||||||
|
));
|
||||||
|
|
||||||
dd($note);
|
foreach ($note->getTerms() as $term) {
|
||||||
dd($note->toAnki());
|
assert($term instanceof Term);
|
||||||
|
|
||||||
return $this->render('anki/index.html.twig', [
|
if (key_exists($term->getKanji(), $kanjiNotes)) continue;
|
||||||
'controller_name' => $latestId,
|
|
||||||
|
$newNote = new Note();
|
||||||
|
|
||||||
|
echo $note->getFields()['SentKanji'];
|
||||||
|
echo '<br><small>';
|
||||||
|
echo var_dump($note->getHighlightedKanji());
|
||||||
|
echo '</small><br><br>';
|
||||||
|
//echo $term->getKanji();
|
||||||
|
//echo ' | ';
|
||||||
|
|
||||||
|
$kanjiNotes[$term->getKanji()] = $newNote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/nonconforming', name: 'nonconforming')]
|
||||||
|
public function nonconforming(): Response
|
||||||
|
{
|
||||||
|
$allIds = $this->ankiService->getAllNoteIds();
|
||||||
|
|
||||||
|
$allNotes = $this->ankiService->getNotes($allIds);
|
||||||
|
|
||||||
|
$lacking = array_filter($allNotes, function ($note) {
|
||||||
|
assert($note instanceof Note);
|
||||||
|
return empty($note->getTerms());
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->render(self::tmpl('nonconforming'), [
|
||||||
|
'notes' => $lacking,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/note/{nid}/get', name: 'get_note')]
|
#[Route('/note/{nid}/get', name: 'get_note')]
|
||||||
public function get_note(int $nid)
|
public function get_note(int $nid)
|
||||||
{
|
{
|
||||||
dd($this->ankiService->getNote($nid));
|
$note = $this->ankiService->getNote($nid);
|
||||||
|
//$this->ankiService->updateNote($note);
|
||||||
|
dd($note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,50 +2,57 @@
|
||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
//use App\Repository\NoteRepository;
|
|
||||||
//use Doctrine\ORM\Mapping as ORM;
|
|
||||||
|
|
||||||
//#[ORM\Entity(repositoryClass: NoteRepository::class)]
|
|
||||||
class Note
|
class Note
|
||||||
{
|
{
|
||||||
//#[ORM\Id]
|
|
||||||
//#[ORM\GeneratedValue]
|
|
||||||
//#[ORM\Column]
|
|
||||||
|
|
||||||
private int $id;
|
|
||||||
private int $mod;
|
|
||||||
private array $terms = [];
|
|
||||||
private string $profile;
|
|
||||||
private array $tags = [];
|
|
||||||
private string $model;
|
|
||||||
// Maybe these doesn't make sense to keep but leaving it here just in
|
|
||||||
// case for handiness' sake
|
|
||||||
private array $fields = [];
|
|
||||||
private ?array $mediaInfo = null;
|
|
||||||
private array $cardIds;
|
|
||||||
|
|
||||||
const HIGHLIGHT_PATTERN = '/<span\s+([^>]*)>(.*?)<\/span>/i';
|
const HIGHLIGHT_PATTERN = '/<span\s+([^>]*)>(.*?)<\/span>/i';
|
||||||
const HIGHLIGHT_ATTR_KANJI = 'style="color: rgb(255, 78, 8);"';
|
const HIGHLIGHT_ATTR_KANJI = 'style="color: rgb(255, 78, 8);"';
|
||||||
|
|
||||||
|
protected ?int $id;
|
||||||
|
protected ?int $mod;
|
||||||
|
protected string $model;
|
||||||
|
protected string $profile;
|
||||||
|
protected array $cardIds = [];
|
||||||
|
protected array $fields = [];
|
||||||
|
protected array $tags = [];
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------- Getters & setters ---
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
public function setId(int $id): static
|
||||||
public function hasTerm(string $kanji): bool
|
|
||||||
{
|
{
|
||||||
foreach ($this->terms as $term) {
|
$this->id = $id;
|
||||||
assert($term instanceof Term);
|
return $this;
|
||||||
|
}
|
||||||
if ($term->kanji == $kanji) return true;
|
public function getModel(): string
|
||||||
}
|
{
|
||||||
|
return $this->model;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromAnki(array $noteInfo): self
|
public function getFields(): array
|
||||||
{
|
{
|
||||||
$note = new self();
|
return $this->fields;
|
||||||
|
}
|
||||||
|
public function setFields(array $fields): static
|
||||||
|
{
|
||||||
|
$this->fields = $fields;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
/** @return list<string> */
|
||||||
|
public function getTags(): array
|
||||||
|
{
|
||||||
|
return $this->tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Anki-related ---
|
||||||
|
|
||||||
|
public static function fromAnki(array $noteInfo): static
|
||||||
|
{
|
||||||
|
$note = new static();
|
||||||
|
|
||||||
[
|
[
|
||||||
'noteId' => $note->id,
|
'noteId' => $note->id,
|
||||||
|
@ -64,49 +71,6 @@ class Note
|
||||||
// the order would be advisable.
|
// the order would be advisable.
|
||||||
$note->fields = array_map(fn($x) => $x['value'], $noteInfo['fields']);
|
$note->fields = array_map(fn($x) => $x['value'], $noteInfo['fields']);
|
||||||
|
|
||||||
$note->mediaInfo = $note->parseMediaInfo($note->fields['Notes']);
|
|
||||||
|
|
||||||
// Set VocabKanji field
|
|
||||||
$terms = Term::fromNoteFields($note->fields);
|
|
||||||
|
|
||||||
// If not defined, find them from the highlighted parts in the sentence
|
|
||||||
if (empty($terms)) {
|
|
||||||
// 1. Get all spans in the text
|
|
||||||
preg_match_all(
|
|
||||||
self::HIGHLIGHT_PATTERN,
|
|
||||||
$note->fields['SentKanji'],
|
|
||||||
$matches,
|
|
||||||
PREG_SET_ORDER,
|
|
||||||
);
|
|
||||||
// 2. Check the ones that match with the kanji color
|
|
||||||
foreach ($matches as $match) {
|
|
||||||
if ($match[1] === self::HIGHLIGHT_ATTR_KANJI) {
|
|
||||||
$term = new Term();
|
|
||||||
$term->kanji = mb_trim($match[2]);
|
|
||||||
$term->definitionEn = null;
|
|
||||||
$term->definitionJp = null;
|
|
||||||
$note->terms[] = $term;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($terms)) dd($note);
|
|
||||||
|
|
||||||
// Set to null whatever is null
|
|
||||||
$readings = array_map(
|
|
||||||
fn($x) => in_array($x, ['_', '_', '']) ? null : $x,
|
|
||||||
explode('|', $note->fields['VocabFurigana']),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set readings from furigana field
|
|
||||||
foreach ($note->terms as $key => &$term) {
|
|
||||||
if (null === $term->getReading()) {
|
|
||||||
if (null !== ($readings[$key] ?? null)) {
|
|
||||||
$term->kanji .= '[' . $readings[$key] . ']';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $note;
|
return $note;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,65 +78,11 @@ class Note
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'fields' => [
|
|
||||||
'VocabKanji' => join('|', array_map(
|
|
||||||
fn(Term $x) => $x->getKanji(),
|
|
||||||
$this->terms,
|
|
||||||
)),
|
|
||||||
'VocabFurigana' => join('|', array_map(
|
|
||||||
fn(Term $x) => $x->getReading() ?? '_',
|
|
||||||
$this->terms,
|
|
||||||
)),
|
|
||||||
'VocabDef' => join("<br>\n", array_map(
|
|
||||||
fn(Term $x) => $x->toAnkiVocabDef(),
|
|
||||||
$this->terms,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseMediaInfo(string $notes): ?array
|
|
||||||
{
|
|
||||||
$matches = null;
|
|
||||||
|
|
||||||
// Parse the notes fields. It can be in the form of
|
// ---------------------------------------------------- Derived methods ---
|
||||||
// series-name_S01 EP07 (11h22m33s44ms)
|
|
||||||
// or
|
|
||||||
// movie-name EP (11h22m33s44ms)
|
|
||||||
if (1 !== preg_match(
|
|
||||||
'/(?<name>[a-z\-_]+)(_S)?(?<season>\d*) EP(?<episode>\d*) \((?<time>.*)\)/n',
|
|
||||||
$notes,
|
|
||||||
$matches,
|
|
||||||
)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove number-indexed matches, cast numbers to integers
|
|
||||||
$matches = [
|
|
||||||
'name' => $matches['name'],
|
|
||||||
'time' => $matches['time'],
|
|
||||||
// NOTE: intval returns 0 if not a number, which is false-like
|
|
||||||
'season' => intval($matches['season']) ?: null,
|
|
||||||
'episode' => intval($matches['episode']) ?: null,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Parse time into a DateInterval and replace it in the matches array
|
|
||||||
$time = new \DateInterval('PT0S');
|
|
||||||
|
|
||||||
preg_match('/(\d+)ms/', $matches['time'], $milliseconds);
|
|
||||||
preg_match('/(\d+)s/', $matches['time'], $seconds);
|
|
||||||
preg_match('/(\d+)m/', $matches['time'], $minutes);
|
|
||||||
preg_match('/(\d+)h/', $matches['time'], $hours);
|
|
||||||
|
|
||||||
if ($milliseconds[1] ?? false) $time->f = $milliseconds[1] * 1000;
|
|
||||||
if ($seconds[1] ?? false) $time->s = $seconds[1];
|
|
||||||
if ($minutes[1] ?? false) $time->i = $minutes[1];
|
|
||||||
if ($hours[1] ?? false) $time->h = $hours[1];
|
|
||||||
|
|
||||||
$matches['time'] = $time;
|
|
||||||
|
|
||||||
return $matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): \DateTimeImmutable
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
class SentenceListeningNote extends Note
|
||||||
|
{
|
||||||
|
const MODEL_NAME = 'Japanese sentences listening';
|
||||||
|
|
||||||
|
private ?array $mediaInfo = [];
|
||||||
|
private ?Term $term = null;
|
||||||
|
|
||||||
|
// -------------------------------------------------- Getters & setters ---
|
||||||
|
|
||||||
|
public function getTerm(): Term
|
||||||
|
{
|
||||||
|
return $this->term;
|
||||||
|
}
|
||||||
|
public function setTerm(Term $term): static
|
||||||
|
{
|
||||||
|
$this->fields['VocabKanji'] = $term->getKanji();
|
||||||
|
$this->fields['VocabFurigana'] = $term->getReading();
|
||||||
|
$this->fields['VocabDef'] = $term->toAnkiVocabDef();
|
||||||
|
$this->term = $term;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Anki-related ---
|
||||||
|
|
||||||
|
public static function fromAnki(array $noteInfo): static
|
||||||
|
{
|
||||||
|
$note = parent::fromAnki($noteInfo);
|
||||||
|
|
||||||
|
if ($note->getModel() !== self::MODEL_NAME) {
|
||||||
|
throw new \Exception('Trying to parse wrong model');
|
||||||
|
}
|
||||||
|
|
||||||
|
$note->mediaInfo = self::parseMediaInfo($note->fields['Notes']);
|
||||||
|
|
||||||
|
// Set VocabKanji field
|
||||||
|
$note->term = Term::fromNoteFields($note->fields)[0] ?? null;
|
||||||
|
if ($note->term === null) {
|
||||||
|
throw new \Exception("Couldn't get term for Listening card");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromNote(Note $origNote, Term $term): static
|
||||||
|
{
|
||||||
|
$slNote = new static();
|
||||||
|
foreach (get_object_vars($origNote) as $prop => $value) {
|
||||||
|
$slNote->$prop = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Related fields are updated using the setter
|
||||||
|
$slNote->setTerm($term);
|
||||||
|
// Remove highlighting
|
||||||
|
$slNote->fields['SentKanji'] = strip_tags($slNote->fields['SentKanji']);
|
||||||
|
// Reset relations and basic data
|
||||||
|
$slNote->id = null;
|
||||||
|
$slNote->model = self::MODEL_NAME;
|
||||||
|
$slNote->cardIds = [];
|
||||||
|
|
||||||
|
return $slNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toAnki(): array
|
||||||
|
{
|
||||||
|
return $this->fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Derived methods ---
|
||||||
|
|
||||||
|
public function isSentKanjiHighlighted(): bool
|
||||||
|
{
|
||||||
|
return str_contains(
|
||||||
|
$this->fields['SentKanji'],
|
||||||
|
self::HIGHLIGHT_ATTR_KANJI,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseMediaInfo(string $notes): ?array
|
||||||
|
{
|
||||||
|
$matches = null;
|
||||||
|
|
||||||
|
// Parse the notes fields. It can be in the form of
|
||||||
|
// series-name_S01 EP07 (11h22m33s44ms)
|
||||||
|
// or
|
||||||
|
// movie-name EP (11h22m33s44ms)
|
||||||
|
if (1 !== preg_match(
|
||||||
|
'/(?<name>[0-9A-Za-z\-_]+)(_S)?(?<season>\d*) EP(?<episode>\d*) \((?<time>.*)\)/n',
|
||||||
|
$notes,
|
||||||
|
$matches,
|
||||||
|
)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove number-indexed matches, cast numbers to integers
|
||||||
|
$matches = [
|
||||||
|
'name' => $matches['name'],
|
||||||
|
'time' => $matches['time'],
|
||||||
|
// NOTE: intval returns 0 if not a number, which is false-like
|
||||||
|
'season' => intval($matches['season']) ?: null,
|
||||||
|
'episode' => intval($matches['episode']) ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Parse time into a DateInterval and replace it in the matches array
|
||||||
|
$time = new \DateInterval('PT0S');
|
||||||
|
|
||||||
|
preg_match('/(\d+)ms/', $matches['time'], $milliseconds);
|
||||||
|
preg_match('/(\d+)s/', $matches['time'], $seconds);
|
||||||
|
preg_match('/(\d+)m/', $matches['time'], $minutes);
|
||||||
|
preg_match('/(\d+)h/', $matches['time'], $hours);
|
||||||
|
|
||||||
|
if ($milliseconds[1] ?? false) $time->f = $milliseconds[1] * 1000;
|
||||||
|
if ($seconds[1] ?? false) $time->s = $seconds[1];
|
||||||
|
if ($minutes[1] ?? false) $time->i = $minutes[1];
|
||||||
|
if ($hours[1] ?? false) $time->h = $hours[1];
|
||||||
|
|
||||||
|
$matches['time'] = $time;
|
||||||
|
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
class SentenceNote extends Note
|
||||||
|
{
|
||||||
|
const MODEL_NAME = 'Japanese sentences';
|
||||||
|
|
||||||
|
private ?array $mediaInfo = [];
|
||||||
|
private array $terms = [];
|
||||||
|
|
||||||
|
// -------------------------------------------------- Getters & setters ---
|
||||||
|
|
||||||
|
public function getTerms(): array
|
||||||
|
{
|
||||||
|
return $this->terms;
|
||||||
|
}
|
||||||
|
public function setTerms(array $terms): static
|
||||||
|
{
|
||||||
|
$this->terms = $terms;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Anki-related ---
|
||||||
|
|
||||||
|
public static function fromAnki(array $noteInfo): static
|
||||||
|
{
|
||||||
|
$note = parent::fromAnki($noteInfo);
|
||||||
|
|
||||||
|
if ($note->getModel() !== self::MODEL_NAME) {
|
||||||
|
throw new \Exception('Trying to parse wrong model');
|
||||||
|
}
|
||||||
|
|
||||||
|
$note->mediaInfo = self::parseMediaInfo($note->fields['Notes']);
|
||||||
|
|
||||||
|
// Set VocabKanji field
|
||||||
|
$note->terms = Term::fromNoteFields($note->fields);
|
||||||
|
|
||||||
|
// If unable to, create them from the highlighted parts in the sentence
|
||||||
|
if (empty($note->terms)) {
|
||||||
|
foreach ($note->getHighlightedKanji() as $highlighedKanji) {
|
||||||
|
$term = new Term();
|
||||||
|
$term->kanji = $highlighedKanji;
|
||||||
|
$term->definitionEn = null;
|
||||||
|
$term->definitionJp = null;
|
||||||
|
$note->terms[] = $term;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set to null whatever is null
|
||||||
|
$readings = array_map(
|
||||||
|
fn($x) => in_array($x, ['_', '_', '']) ? null : $x,
|
||||||
|
explode('|', $note->fields['VocabFurigana']),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set readings from furigana field
|
||||||
|
foreach ($note->terms as $key => &$term) {
|
||||||
|
if (null === $term->getReading()) {
|
||||||
|
if (null !== ($readings[$key] ?? null)) {
|
||||||
|
$term->kanji .= '[' . $readings[$key] . ']';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toAnki(): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::toAnki(), [
|
||||||
|
'fields' => [
|
||||||
|
'VocabKanji' => join('|', array_map(
|
||||||
|
fn(Term $x) => $x->getKanji(),
|
||||||
|
$this->terms,
|
||||||
|
)),
|
||||||
|
'VocabFurigana' => join('|', array_map(
|
||||||
|
fn(Term $x) => $x->getReading() ?? '_',
|
||||||
|
$this->terms,
|
||||||
|
)),
|
||||||
|
'VocabDef' => join("<br>\n", array_map(
|
||||||
|
fn(Term $x) => $x->toAnkiVocabDef(),
|
||||||
|
$this->terms,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------- Derived methods ---
|
||||||
|
|
||||||
|
public function hasTerm(string $kanji): bool
|
||||||
|
{
|
||||||
|
foreach ($this->terms as $term) {
|
||||||
|
assert($term instanceof Term);
|
||||||
|
|
||||||
|
if ($term->kanji == $kanji) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSentKanjiHighlighted(): bool
|
||||||
|
{
|
||||||
|
return str_contains(
|
||||||
|
$this->fields['SentKanji'],
|
||||||
|
self::HIGHLIGHT_ATTR_KANJI,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return an array of strings with the highlighted kanji in the SentKanji */
|
||||||
|
public function getHighlightedKanji(): array
|
||||||
|
{
|
||||||
|
$ret = [];
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
// 1. Get all spans in the text
|
||||||
|
preg_match_all(
|
||||||
|
self::HIGHLIGHT_PATTERN,
|
||||||
|
$this->fields['SentKanji'],
|
||||||
|
$matches,
|
||||||
|
PREG_SET_ORDER,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Check the ones that match with the kanji color
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
if ($match[1] === self::HIGHLIGHT_ATTR_KANJI) {
|
||||||
|
$ret[] = mb_trim($match[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseMediaInfo(string $notes): ?array
|
||||||
|
{
|
||||||
|
$matches = null;
|
||||||
|
|
||||||
|
// Parse the notes fields. It can be in the form of
|
||||||
|
// series-name_S01 EP07 (11h22m33s44ms)
|
||||||
|
// or
|
||||||
|
// movie-name EP (11h22m33s44ms)
|
||||||
|
if (1 !== preg_match(
|
||||||
|
'/(?<name>[0-9A-Za-z\-_]+)(_S)?(?<season>\d*) EP(?<episode>\d*) \((?<time>.*)\)/n',
|
||||||
|
$notes,
|
||||||
|
$matches,
|
||||||
|
)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove number-indexed matches, cast numbers to integers
|
||||||
|
$matches = [
|
||||||
|
'name' => $matches['name'],
|
||||||
|
'time' => $matches['time'],
|
||||||
|
// NOTE: intval returns 0 if not a number, which is false-like
|
||||||
|
'season' => intval($matches['season']) ?: null,
|
||||||
|
'episode' => intval($matches['episode']) ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Parse time into a DateInterval and replace it in the matches array
|
||||||
|
$time = new \DateInterval('PT0S');
|
||||||
|
|
||||||
|
preg_match('/(\d+)ms/', $matches['time'], $milliseconds);
|
||||||
|
preg_match('/(\d+)s/', $matches['time'], $seconds);
|
||||||
|
preg_match('/(\d+)m/', $matches['time'], $minutes);
|
||||||
|
preg_match('/(\d+)h/', $matches['time'], $hours);
|
||||||
|
|
||||||
|
if ($milliseconds[1] ?? false) $time->f = $milliseconds[1] * 1000;
|
||||||
|
if ($seconds[1] ?? false) $time->s = $seconds[1];
|
||||||
|
if ($minutes[1] ?? false) $time->i = $minutes[1];
|
||||||
|
if ($hours[1] ?? false) $time->h = $hours[1];
|
||||||
|
|
||||||
|
$matches['time'] = $time;
|
||||||
|
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Note;
|
use App\Entity\Note;
|
||||||
|
use App\Entity\SentenceListeningNote;
|
||||||
|
use App\Entity\SentenceNote;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
class AnkiService
|
class AnkiService
|
||||||
|
@ -30,7 +32,38 @@ class AnkiService
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAllNoteIds(): array
|
/** The note's id is updated on success.
|
||||||
|
* @return bool True on success
|
||||||
|
*/
|
||||||
|
public function addNote(Note &$note, string $deckName): bool
|
||||||
|
{
|
||||||
|
$note->setId($this->request('addNote', ['note' => [
|
||||||
|
'deckName' => $deckName,
|
||||||
|
'modelName' => $note->getModel(),
|
||||||
|
'fields' => $note->getFields(),
|
||||||
|
'options' => ['allowDuplicate' => false],
|
||||||
|
'tags' => array_merge($note->getTags(), ['anker_made']),
|
||||||
|
]]));
|
||||||
|
|
||||||
|
return $note->getId() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findNotesIds(string $query): array
|
||||||
|
{
|
||||||
|
return $this->request('findNotes', ['query' => $query]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllSentenceListeningNoteIds(): array
|
||||||
|
{
|
||||||
|
$query = sprintf(
|
||||||
|
'"note:%s" -is:suspended',
|
||||||
|
SentenceListeningNote::MODEL_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->request('findNotes', ['query' => $query]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllSentenceNoteIds(): array
|
||||||
{
|
{
|
||||||
return $this->request(
|
return $this->request(
|
||||||
'findNotes',
|
'findNotes',
|
||||||
|
@ -41,7 +74,7 @@ class AnkiService
|
||||||
/** Give an array of IDs, the note Infos are returned. if info for a given
|
/** Give an array of IDs, the note Infos are returned. if info for a given
|
||||||
* doesn't exist, it is assigned to null instead of the default [].
|
* doesn't exist, it is assigned to null instead of the default [].
|
||||||
*/
|
*/
|
||||||
private function getNotesInfo(array $noteIds): array
|
public function getNotesInfo(array $noteIds): array
|
||||||
{
|
{
|
||||||
$noteInfos = $this->request('notesInfo', ['notes' => $noteIds]);
|
$noteInfos = $this->request('notesInfo', ['notes' => $noteIds]);
|
||||||
|
|
||||||
|
@ -53,21 +86,42 @@ class AnkiService
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns info form note given an ID, returns null if it doesn't exist */
|
/** Returns info form note given an ID, returns null if it doesn't exist */
|
||||||
private function getNoteInfo(int $noteId): ?array
|
public function getNoteInfo(int $noteId): ?array
|
||||||
{
|
{
|
||||||
return $this->getNotesInfo([$noteId])[0];
|
return $this->getNotesInfo([$noteId])[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getNotes(array $nids): array
|
||||||
|
{
|
||||||
|
return array_map(self::parseNoteInfo(...), $this->getNotesInfo($nids));
|
||||||
|
}
|
||||||
|
|
||||||
public function getNote(int $nid): ?Note
|
public function getNote(int $nid): ?Note
|
||||||
{
|
{
|
||||||
return Note::fromAnki($this->getNoteInfo($nid));
|
$noteInfo = $this->getNoteInfo($nid)
|
||||||
|
?? throw new \Exception("Note $nid not found.");
|
||||||
|
|
||||||
|
return self::parseNoteInfo($noteInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseNoteInfo(array $noteInfo): Note
|
||||||
|
{
|
||||||
|
return match ($noteInfo['modelName']) {
|
||||||
|
SentenceNote::MODEL_NAME => SentenceNote::fromAnki($noteInfo),
|
||||||
|
SentenceListeningNote::MODEL_NAME => SentenceListeningNote::fromAnki($noteInfo),
|
||||||
|
default => throw new \Exception(sprintf(
|
||||||
|
'Unrecognized Note "%s" of type "%s"',
|
||||||
|
$noteInfo['noteId'],
|
||||||
|
$noteInfo['modelName'],
|
||||||
|
))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLatestNote(): ?Note
|
public function getLatestNote(): ?Note
|
||||||
{
|
{
|
||||||
// NoteIDs are just timestamps in milliseconds, so the latest is just
|
// NoteIDs are just timestamps in milliseconds, so the latest is just
|
||||||
// the biggest numerically
|
// the biggest numerically
|
||||||
$latestId = max($this->getAllNoteIds());
|
$latestId = max($this->getAllSentenceNoteIds());
|
||||||
return $this->getNote($latestId);
|
return $this->getNote($latestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
{% extends 'base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}Hello AnkiController!{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<style>
|
|
||||||
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
|
|
||||||
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="example-wrapper">
|
|
||||||
<h1>Hello {{ controller_name }}! ✅</h1>
|
|
||||||
|
|
||||||
This friendly message is coming from:
|
|
||||||
<ul>
|
|
||||||
<li>Your controller at <code>/strg/prgm/web/anker/src/Controller/AnkiController.php</code></li>
|
|
||||||
<li>Your template at <code>/strg/prgm/web/anker/templates/anki/index.html.twig</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Main{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="example-wrapper">
|
||||||
|
<h1>hello</h1>
|
||||||
|
<span>total {{ notes|length }}</span>
|
||||||
|
{% for note in notes %}
|
||||||
|
<div style="
|
||||||
|
border: 1px solid black;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
">
|
||||||
|
{{ note.fields.SentKanji }}
|
||||||
|
<br>
|
||||||
|
<a href="{{ path('app_anki_get_note', {nid: note.id}) }}">view</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue