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';
|
||||
|
||||
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;
|
||||
|
||||
use App\Entity\Note;
|
||||
use App\Entity\SentenceNote;
|
||||
use App\Entity\Term;
|
||||
use App\Service\AnkiService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
@ -14,24 +17,67 @@ class AnkiController extends AbstractController
|
|||
private AnkiService $ankiService,
|
||||
) {}
|
||||
|
||||
public static function tmpl(string $path): string
|
||||
{
|
||||
return "anki/$path.html.twig";
|
||||
}
|
||||
|
||||
#[Route('/', name: 'main')]
|
||||
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);
|
||||
dd($note->toAnki());
|
||||
foreach ($note->getTerms() as $term) {
|
||||
assert($term instanceof Term);
|
||||
|
||||
return $this->render('anki/index.html.twig', [
|
||||
'controller_name' => $latestId,
|
||||
if (key_exists($term->getKanji(), $kanjiNotes)) continue;
|
||||
|
||||
$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')]
|
||||
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;
|
||||
|
||||
//use App\Repository\NoteRepository;
|
||||
//use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
//#[ORM\Entity(repositoryClass: NoteRepository::class)]
|
||||
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_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
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function hasTerm(string $kanji): bool
|
||||
public function setId(int $id): static
|
||||
{
|
||||
foreach ($this->terms as $term) {
|
||||
assert($term instanceof Term);
|
||||
|
||||
if ($term->kanji == $kanji) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
public function getModel(): string
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -64,49 +71,6 @@ class Note
|
|||
// the order would be advisable.
|
||||
$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;
|
||||
}
|
||||
|
||||
|
@ -114,65 +78,11 @@ class Note
|
|||
{
|
||||
return [
|
||||
'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
|
||||
// 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;
|
||||
}
|
||||
// ---------------------------------------------------- Derived methods ---
|
||||
|
||||
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;
|
||||
|
||||
use App\Entity\Note;
|
||||
use App\Entity\SentenceListeningNote;
|
||||
use App\Entity\SentenceNote;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class AnkiService
|
||||
|
@ -30,7 +32,38 @@ class AnkiService
|
|||
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(
|
||||
'findNotes',
|
||||
|
@ -41,7 +74,7 @@ class AnkiService
|
|||
/** 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 [].
|
||||
*/
|
||||
private function getNotesInfo(array $noteIds): array
|
||||
public function getNotesInfo(array $noteIds): array
|
||||
{
|
||||
$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 */
|
||||
private function getNoteInfo(int $noteId): ?array
|
||||
public function getNoteInfo(int $noteId): ?array
|
||||
{
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
// NoteIDs are just timestamps in milliseconds, so the latest is just
|
||||
// the biggest numerically
|
||||
$latestId = max($this->getAllNoteIds());
|
||||
$latestId = max($this->getAllSentenceNoteIds());
|
||||
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