Compare commits
No commits in common. "3ac2cd731909254a1a79ec7a22eb7299f13f8b0d" and "0ac5d035a205d4854366864d68268f005a8b84c5" have entirely different histories.
3ac2cd7319
...
0ac5d035a2
|
@ -1 +1,10 @@
|
||||||
|
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! 🎉');
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
body {
|
||||||
|
background-color: skyblue;
|
||||||
|
}
|
|
@ -1,242 +0,0 @@
|
||||||
<?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,9 +2,6 @@
|
||||||
|
|
||||||
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;
|
||||||
|
@ -17,67 +14,24 @@ 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
|
||||||
{
|
{
|
||||||
$allIds = $this->ankiService->getAllSentenceNoteIds();
|
$note = $this->ankiService->getLatestNote();
|
||||||
$allNotes = $this->ankiService->getNotes($allIds);
|
|
||||||
|
|
||||||
$kanjiNotes = [];
|
$this->ankiService->updateNote($note);
|
||||||
foreach ($allNotes as $note) {
|
|
||||||
if (!$note instanceof SentenceNote) throw new \Exception(sprintf(
|
|
||||||
'Expected SentenceNote, got %s',
|
|
||||||
$note::class,
|
|
||||||
));
|
|
||||||
|
|
||||||
foreach ($note->getTerms() as $term) {
|
dd($note);
|
||||||
assert($term instanceof Term);
|
dd($note->toAnki());
|
||||||
|
|
||||||
if (key_exists($term->getKanji(), $kanjiNotes)) continue;
|
return $this->render('anki/index.html.twig', [
|
||||||
|
'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)
|
||||||
{
|
{
|
||||||
$note = $this->ankiService->getNote($nid);
|
dd($this->ankiService->getNote($nid));
|
||||||
//$this->ankiService->updateNote($note);
|
|
||||||
dd($note);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,57 +2,50 @@
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
foreach ($this->terms as $term) {
|
||||||
return $this;
|
assert($term instanceof Term);
|
||||||
}
|
|
||||||
public function getModel(): string
|
if ($term->kanji == $kanji) return true;
|
||||||
{
|
}
|
||||||
return $this->model;
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFields(): array
|
public static function fromAnki(array $noteInfo): self
|
||||||
{
|
{
|
||||||
return $this->fields;
|
$note = new self();
|
||||||
}
|
|
||||||
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,
|
||||||
|
@ -71,6 +64,49 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,11 +114,65 @@ 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;
|
||||||
|
|
||||||
// ---------------------------------------------------- Derived methods ---
|
// 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>[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
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,177 +0,0 @@
|
||||||
<?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,8 +3,6 @@
|
||||||
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
|
||||||
|
@ -32,38 +30,7 @@ class AnkiService
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The note's id is updated on success.
|
public function getAllNoteIds(): array
|
||||||
* @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',
|
||||||
|
@ -74,7 +41,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 [].
|
||||||
*/
|
*/
|
||||||
public function getNotesInfo(array $noteIds): array
|
private function getNotesInfo(array $noteIds): array
|
||||||
{
|
{
|
||||||
$noteInfos = $this->request('notesInfo', ['notes' => $noteIds]);
|
$noteInfos = $this->request('notesInfo', ['notes' => $noteIds]);
|
||||||
|
|
||||||
|
@ -86,42 +53,21 @@ 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 */
|
||||||
public function getNoteInfo(int $noteId): ?array
|
private 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
|
||||||
{
|
{
|
||||||
$noteInfo = $this->getNoteInfo($nid)
|
return Note::fromAnki($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->getAllSentenceNoteIds());
|
$latestId = max($this->getAllNoteIds());
|
||||||
return $this->getNote($latestId);
|
return $this->getNote($latestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% 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 %}
|
|
@ -1,22 +0,0 @@
|
||||||
{% 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