diff --git a/db/migrations/20260702_120000_drop_persistent_scratch_tables.sql b/db/migrations/20260702_120000_drop_persistent_scratch_tables.sql new file mode 100644 index 000000000..9ab819da2 --- /dev/null +++ b/db/migrations/20260702_120000_drop_persistent_scratch_tables.sql @@ -0,0 +1,21 @@ +-- Migration: Drop the persistent parse/import scratch tables. +-- +-- temp_word_occurrences, temp_words and tempexprs used to be persistent, +-- globally-shared InnoDB tables that every parse/import TRUNCATEd and refilled. +-- That caused two problems: +-- * "Tablespace is missing for a table" (InnoDB error 194) crashes on +-- TRUNCATE when the table's .ibd file went missing (notably on Windows) — +-- every text import then failed. See issue #247. +-- * Concurrent parses/imports corrupted each other's rows (no isolation). +-- +-- They are now created per database connection as CREATE TEMPORARY TABLE at +-- runtime (see src/Shared/Infrastructure/Database/ScratchTables.php), so the +-- persistent versions are no longer needed. Dropping them here also repairs +-- installs whose tablespace was already orphaned (the reported crash). +-- +-- DROP TABLE IF EXISTS is idempotent and succeeds even when the tablespace is +-- missing, so it clears the orphaned dictionary entry as well. + +DROP TABLE IF EXISTS temp_word_occurrences; +DROP TABLE IF EXISTS temp_words; +DROP TABLE IF EXISTS tempexprs; diff --git a/db/schema/baseline.sql b/db/schema/baseline.sql index e21f3c2f1..dc438f327 100644 --- a/db/schema/baseline.sql +++ b/db/schema/baseline.sql @@ -112,23 +112,12 @@ CREATE TABLE IF NOT EXISTS word_occurrences ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -CREATE TABLE IF NOT EXISTS temp_word_occurrences ( - TiCount smallint(5) unsigned NOT NULL, - TiSeID mediumint(8) unsigned NOT NULL, - TiOrder smallint(5) unsigned NOT NULL, - TiWordCount tinyint(3) unsigned NOT NULL, - TiText varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS temp_words ( - WoText varchar(250) DEFAULT NULL, - WoTextLC varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, - WoTranslation varchar(500) NOT NULL DEFAULT '*', - WoRomanization varchar(100) DEFAULT NULL, - WoSentence varchar(1000) DEFAULT NULL, - WoTaglist varchar(255) DEFAULT NULL, - PRIMARY KEY(WoTextLC) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- The parse/import scratch tables (temp_word_occurrences, temp_words, tempexprs) +-- are no longer persistent shared tables. They are created per database +-- connection as CREATE TEMPORARY TABLE at runtime (see +-- src/Shared/Infrastructure/Database/ScratchTables.php). This avoids InnoDB +-- "Tablespace is missing" (error 194) crashes on TRUNCATE and isolates +-- concurrent parses/imports from each other. See issue #247. CREATE TABLE IF NOT EXISTS texts ( TxID smallint(5) unsigned NOT NULL AUTO_INCREMENT, diff --git a/src/Modules/Language/Application/Services/ParsingCoordinator.php b/src/Modules/Language/Application/Services/ParsingCoordinator.php deleted file mode 100644 index 3b4be44d1..000000000 --- a/src/Modules/Language/Application/Services/ParsingCoordinator.php +++ /dev/null @@ -1,348 +0,0 @@ - - * @license Unlicense - * @link https://hugofara.github.io/lwt/developer/api - * @since 3.0.0 - */ - -declare(strict_types=1); - -namespace Lwt\Modules\Language\Application\Services; - -use Lwt\Modules\Language\Domain\Language; -use Lwt\Modules\Language\Domain\Parser\ParserConfig; -use Lwt\Modules\Language\Domain\Parser\ParserResult; -use Lwt\Modules\Language\Infrastructure\Parser\ParserRegistry; -use Lwt\Shared\Infrastructure\Globals; -use Lwt\Shared\Infrastructure\Database\Connection; -use Lwt\Shared\Infrastructure\Database\Escaping; -use Lwt\Shared\Infrastructure\Database\QueryBuilder; -use Lwt\Shared\Infrastructure\Database\UserScopedQuery; - -/** - * Coordinates parsing operations, providing a facade for the parser system. - * - * This class handles parser selection, preprocessing, and database persistence. - * It serves as the main entry point for text parsing operations. - * - * @since 3.0.0 - */ -class ParsingCoordinator -{ - public function __construct( - private ParserRegistry $registry - ) { - } - - /** - * Split text into sentences without database operations. - * - * @param string $text Text to parse - * @param Language $language Language entity - * - * @return string[] Array of sentences - */ - public function splitIntoSentences(string $text, Language $language): array - { - $text = $this->preprocess($text, $language); - $config = ParserConfig::fromLanguage($language); - $parser = $this->registry->getParserForLanguage($language); - - $result = $parser->parse($text, $config); - return $result->getSentences(); - } - - /** - * Parse text and return the result without database operations. - * - * @param string $text Text to parse - * @param Language $language Language entity - * - * @return ParserResult Parsing result - */ - public function parseForPreview(string $text, Language $language): ParserResult - { - $text = $this->preprocess($text, $language); - $config = ParserConfig::fromLanguage($language); - $parser = $this->registry->getParserForLanguage($language); - - return $parser->parse($text, $config); - } - - /** - * Parse text and save to database. - * - * @param string $text Text to parse - * @param Language $language Language entity - * @param int $textId Text ID (must be positive) - * - * @return void - * - * @throws \InvalidArgumentException If textId is not positive - */ - public function parseAndSave(string $text, Language $language, int $textId): void - { - if ($textId <= 0) { - throw new \InvalidArgumentException( - "Text ID must be positive, got: $textId" - ); - } - - $text = $this->preprocess($text, $language); - $config = ParserConfig::fromLanguage($language); - $parser = $this->registry->getParserForLanguage($language); - - $result = $parser->parse($text, $config); - - $this->saveToDatabase($result, $language, $textId); - } - - /** - * Preprocess text before parsing. - * - * Applies character substitutions and other text cleanup. - * - * @param string $text Raw text - * @param Language $language Language entity - * - * @return string Preprocessed text - */ - protected function preprocess(string $text, Language $language): string - { - $text = Escaping::prepareTextdata($text); - - // Replace special characters that interfere with sentence parsing - $text = str_replace(['}', '{'], [']', '['], $text); - - // Apply character substitutions - $substitutions = $language->characterSubstitutions(); - if ($substitutions !== '') { - foreach (explode('|', $substitutions) as $value) { - $fromto = explode('=', trim($value)); - if (count($fromto) >= 2) { - $text = str_replace(trim($fromto[0]), trim($fromto[1]), $text); - } - } - } - - return $text; - } - - /** - * Save parsing result to database. - * - * @param ParserResult $result Parsing result - * @param Language $language Language entity - * @param int $textId Text ID - * - * @return void - */ - protected function saveToDatabase( - ParserResult $result, - Language $language, - int $textId - ): void { - $lid = $language->id()->toInt(); - - // Clear temporary table - QueryBuilder::table('temp_word_occurrences')->truncate(); - - // Get next sentence ID - $dbname = Globals::getDatabaseName(); - $sentencesTable = Globals::table('sentences'); - $nextSeID = (int) Connection::preparedFetchValue( - "SELECT AUTO_INCREMENT as value FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", - [$dbname, $sentencesTable] - ); - if ($nextSeID <= 0) { - $bindings = []; - $nextSeID = (int) Connection::preparedFetchValue( - "SELECT IFNULL(MAX(`SeID`)+1,1) as value FROM sentences WHERE 1=1" - . UserScopedQuery::forTablePrepared('sentences', $bindings), - $bindings - ); - } - - // Insert tokens into temp_word_occurrences - $this->insertTokensToTemp($result, $nextSeID); - - // Check for multi-word expressions - $hasMultiword = $this->checkExpressions($lid); - - // Register sentences and text items - $this->registerSentencesTextItems($textId, $lid, $hasMultiword); - } - - /** - * Insert tokens into temp_word_occurrences table. - * - * @param ParserResult $result Parsing result - * @param int $startSeID Starting sentence ID - * - * @return void - */ - protected function insertTokensToTemp(ParserResult $result, int $startSeID): void - { - $tokens = $result->getTokens(); - if (empty($tokens)) { - return; - } - - $rows = []; - $currentSeID = $startSeID; - $lastSentenceIndex = -1; - $count = 0; - - foreach ($tokens as $token) { - // Track sentence changes - if ($token->getSentenceIndex() !== $lastSentenceIndex) { - if ($lastSentenceIndex >= 0) { - $currentSeID++; - $count = 0; - } - $lastSentenceIndex = $token->getSentenceIndex(); - } - - $tiSeID = $currentSeID; - $tiCount = $count + 1; - $count += mb_strlen($token->getText()); - $tiOrder = $token->getOrder() + 1; // 1-based in DB - $tiWordCount = $token->isWord() ? 1 : 0; - - $rows[] = [$tiSeID, $tiCount, $tiOrder, $token->getText(), $tiWordCount]; - } - - $placeholders = []; - $flatParams = []; - foreach ($rows as $row) { - $placeholders[] = "(?, ?, ?, ?, ?)"; - $flatParams = array_merge($flatParams, $row); - } - - Connection::preparedExecute( - "INSERT INTO temp_word_occurrences ( - TiSeID, TiCount, TiOrder, TiText, TiWordCount - ) VALUES " . implode(',', $placeholders), - $flatParams - ); - } - - /** - * Check for multi-word expressions and populate tempexprs. - * - * @param int $lid Language ID - * - * @return bool True if multi-word expressions were found - */ - protected function checkExpressions(int $lid): bool - { - // This is a simplified version - the full implementation is in TextParsing - $bindings = [$lid, $lid]; - $result = Connection::preparedFetchAll( - "SELECT WoWordCount FROM words WHERE WoWordCount > 1 AND WoLgID = ?" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " GROUP BY WoWordCount", - $bindings - ); - - return !empty($result); - } - - /** - * Register sentences and text items in the database. - * - * @param int $tid Text ID - * @param int $lid Language ID - * @param bool $hasmultiword Whether to process multi-word expressions - * - * @return void - */ - protected function registerSentencesTextItems( - int $tid, - int $lid, - bool $hasmultiword - ): void { - // Insert sentences - Connection::query('SET @i=0;'); - Connection::preparedExecute( - "INSERT INTO sentences ( - SeLgID, SeTxID, SeOrder, SeFirstPos, SeText - ) SELECT - ?, - ?, - @i:=@i+1, - MIN(IF(TiWordCount=0, TiOrder+1, TiOrder)), - GROUP_CONCAT(TiText ORDER BY TiOrder SEPARATOR \"\") - FROM temp_word_occurrences - GROUP BY TiSeID - ORDER BY TiSeID", - [$lid, $tid] - ); - - // Insert text items - if ($hasmultiword) { - // Build SQL and bindings in lockstep so that the two user-scope - // injections inside the UNION land in left-to-right placeholder - // order. Pre-bundling all six values up-front and appending the - // two $userIds at the end mismatches positions 3-7 and swaps - // Ti2LgID/Ti2TxID, triggering an FK violation on word_occurrences. - $bindings = [$lid, $tid]; - $sql = "INSERT INTO word_occurrences ( - Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text - ) SELECT WoID, ?, ?, sent, TiOrder - (2*(n-1)) TiOrder, - n TiWordCount, word - FROM tempexprs - JOIN words - ON WoTextLC = lword AND WoWordCount = n" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " WHERE lword IS NOT NULL AND WoLgID = ?"; - $bindings[] = $lid; - $bindings[] = $lid; - $bindings[] = $tid; - $sql .= " UNION ALL - SELECT WoID, ?, ?, TiSeID, TiOrder, TiWordCount, TiText - FROM temp_word_occurrences - LEFT JOIN words - ON LOWER(TiText) = WoTextLC AND TiWordCount=1 AND WoLgID = ?"; - $bindings[] = $lid; - $sql .= UserScopedQuery::forTablePrepared('words', $bindings, '') - . " ORDER BY TiOrder, TiWordCount"; - - $stmt = Connection::prepare($sql); - $stmt->bindValues($bindings); - $stmt->execute(); - } else { - $bindings = [$lid, $tid, $lid]; - Connection::preparedExecute( - "INSERT INTO word_occurrences ( - Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text - ) SELECT WoID, ?, ?, TiSeID, TiOrder, TiWordCount, TiText - FROM temp_word_occurrences - LEFT JOIN words - ON LOWER(TiText) = WoTextLC AND TiWordCount=1 AND WoLgID = ?" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " ORDER BY TiSeID, TiOrder, TiWordCount", - $bindings - ); - } - } - - /** - * Get the parser registry. - * - * @return ParserRegistry Parser registry - */ - public function getRegistry(): ParserRegistry - { - return $this->registry; - } -} diff --git a/src/Modules/Language/LanguageServiceProvider.php b/src/Modules/Language/LanguageServiceProvider.php index ae26161e5..08759e013 100644 --- a/src/Modules/Language/LanguageServiceProvider.php +++ b/src/Modules/Language/LanguageServiceProvider.php @@ -47,7 +47,6 @@ // Parser Infrastructure use Lwt\Modules\Language\Infrastructure\Parser\ExternalParserLoader; use Lwt\Modules\Language\Infrastructure\Parser\ParserRegistry; -use Lwt\Modules\Language\Application\Services\ParsingCoordinator; /** * Service provider for the Language module. @@ -85,12 +84,6 @@ public function register(Container $container): void ); }); - $container->singleton(ParsingCoordinator::class, function (Container $c) { - return new ParsingCoordinator( - $c->getTyped(ParserRegistry::class) - ); - }); - // Register Repository Interface binding $container->singleton(LanguageRepositoryInterface::class, function (Container $_c) { return new MySqlLanguageRepository(); diff --git a/src/Modules/Vocabulary/Application/Services/CompleteImportService.php b/src/Modules/Vocabulary/Application/Services/CompleteImportService.php index 728be73ad..89a811800 100644 --- a/src/Modules/Vocabulary/Application/Services/CompleteImportService.php +++ b/src/Modules/Vocabulary/Application/Services/CompleteImportService.php @@ -20,6 +20,7 @@ use Lwt\Shared\Infrastructure\Database\Connection; use Lwt\Shared\Infrastructure\Database\DB; use Lwt\Shared\Infrastructure\Database\QueryBuilder; +use Lwt\Shared\Infrastructure\Database\ScratchTables; use Lwt\Shared\Infrastructure\Database\Settings; use Lwt\Shared\Infrastructure\Database\UserScopedQuery; use Lwt\Modules\Tags\Application\TagsFacade; @@ -131,6 +132,10 @@ public function importComplete( */ private function initTempTables(): void { + // Per-connection scratch table for the import rows (was a persistent + // shared table; see ScratchTables for why it is now TEMPORARY). + ScratchTables::ensureWords(); + Connection::execute("DELETE FROM temp_words"); Connection::execute( "CREATE TEMPORARY TABLE IF NOT EXISTS numbers( n tinyint(3) unsigned NOT NULL @@ -646,8 +651,11 @@ private function handleTagsImport(int $langId): void */ private function cleanupTempTables(): void { - Connection::execute("DROP TABLE IF EXISTS numbers"); - QueryBuilder::table('temp_words')->truncate(); + // DROP TEMPORARY (not DROP TABLE / TRUNCATE): the former does not + // implicitly commit, so it is safe to call inside the import transaction. + // Both tables are recreated by initTempTables() on the next import. + Connection::execute("DROP TEMPORARY TABLE IF EXISTS numbers"); + Connection::execute("DROP TEMPORARY TABLE IF EXISTS temp_words"); } /** @@ -662,6 +670,10 @@ private function cleanupTempTables(): void */ public function importTagsOnly(array $fields, string $tabType, string $fileName, bool $ignoreFirst): void { + // Create the per-connection scratch tables (temp_words + numbers) before + // loading the tag rows into temp_words below. + $this->initTempTables(); + $columns = ''; $tlField = $fields["tl"]; for ($j = 1; $j <= $tlField; $j++) { @@ -736,15 +748,6 @@ public function importTagsOnly(array $fields, string $tabType, string $fileName, } } - // Create numbers table and insert tags - Connection::execute( - "CREATE TEMPORARY TABLE IF NOT EXISTS numbers( - n tinyint(3) unsigned NOT NULL - )" - ); - Connection::execute("INSERT IGNORE INTO numbers(n) VALUES ('1'),('2'),('3'), - ('4'),('5'),('6'),('7'),('8'),('9')"); - // Stamp TgUsID so the imported tags are owned by the caller — // see handleTagsImport() for context. Without this the rows // land with TgUsID NULL and never appear on the user's tag diff --git a/src/Shared/Infrastructure/Database/JapaneseTextParser.php b/src/Shared/Infrastructure/Database/JapaneseTextParser.php index ae37a920e..d420205d2 100644 --- a/src/Shared/Infrastructure/Database/JapaneseTextParser.php +++ b/src/Shared/Infrastructure/Database/JapaneseTextParser.php @@ -23,7 +23,8 @@ /** * Japanese text parsing using MeCab. * - * Handles splitting, previewing, and database insertion for Japanese text. + * Handles splitting, previewing, and tokenization for Japanese text. Produces + * ParsedToken objects consumed by TokenPersistence — no scratch table involved. * * @since 3.0.0 */ @@ -63,14 +64,13 @@ public static function displayJapanesePreview(string $text): void } /** - * Parse Japanese text with MeCab and insert into temp_word_occurrences. + * Tokenize Japanese text with MeCab into ParsedToken objects. * - * @param string $text Preprocessed text - * @param bool $useMaxSeID Whether to query for max sentence ID (true for existing texts) + * @param string $text Preprocessed text (character substitutions applied) * - * @return void + * @return ParsedToken[] */ - public static function parseJapaneseToDatabase(string $text, bool $useMaxSeID): void + public static function tokenize(string $text): array { $text = preg_replace('/[ \t]+/u', ' ', $text) ?? $text; $text = trim($text); @@ -81,7 +81,7 @@ public static function parseJapaneseToDatabase(string $text, bool $useMaxSeID): } try { - // We use the format "word num num" for all nodes + // We use the format "word\ttype\tnode" for all nodes. $mecab_args = " -F %m\\t%t\\t%h\\n -U %m\\t%t\\t%h\\n -E EOP\\t3\\t7\\n"; $mecab_args .= " -o " . escapeshellarg($file_name) . " "; $mecab = (new TextParsingService())->getMecabPath($mecab_args); @@ -93,15 +93,6 @@ public static function parseJapaneseToDatabase(string $text, bool $useMaxSeID): pclose($handle); } - Connection::execute( - "CREATE TEMPORARY TABLE IF NOT EXISTS tempword_occurrences ( - TiCount smallint(5) unsigned NOT NULL, - TiSeID mediumint(8) unsigned NOT NULL, - TiOrder smallint(5) unsigned NOT NULL, - TiWordCount tinyint(3) unsigned NOT NULL, - TiText varchar(250) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL - ) DEFAULT CHARSET=utf8" - ); $handle = fopen($file_name, 'r'); $mecabed = ''; if ($handle !== false) { @@ -112,110 +103,7 @@ public static function parseJapaneseToDatabase(string $text, bool $useMaxSeID): } fclose($handle); } - $values = array(); - $order = 0; - $sid = 1; - if ($useMaxSeID) { - $sid = (int)Connection::fetchValue( - "SELECT IFNULL(MAX(`SeID`)+1,1) as value FROM sentences" - . UserScopedQuery::forTable('sentences') - ); - } - $term_type = 0; - $last_node_type = 0; - $count = 0; - $row = array(0, 0, 0, "", 0); - $separator = mb_chr(9); - if ($separator === false) { - $separator = "\t"; - } - foreach (explode(PHP_EOL, $mecabed) as $line) { - if (trim($line) == "") { - continue; - } - $parts = explode($separator, $line); - $term = $parts[0] ?? ''; - $node_type = $parts[1] ?? ''; - $third = $parts[2] ?? ''; - if ($term_type == 2 || $term == 'EOP' && $third == '7') { - $sid += 1; - } - $row[0] = $sid; // TiSeID - $row[1] = $count + 1; // TiCount - $count += mb_strlen($term); - $last_term_type = $term_type; - if ($third == '7') { - if ($term == 'EOP') { - $term = '¶'; - } - $term_type = 2; - } elseif (in_array($node_type, ['2', '6', '7', '8'])) { - $term_type = 0; - } else { - $term_type = 1; - } - - // Increase word order: - // Once if the current or the previous term were words - // Twice if current or the previous were not of unmanaged type - $order += (int)($term_type == 0 && $last_term_type == 0) + - (int)($term_type != 1 || $last_term_type != 1); - $row[2] = $order; // TiOrder - $row[3] = $term; // TiText (no escaping needed for prepared statement) - $row[4] = $term_type == 0 ? 1 : 0; // TiWordCount - $values[] = $row; - // Special case for kazu (numbers) - if ($last_node_type == 8 && $node_type == 8) { - $lastKey = array_key_last($values); - // $lastKey is int<0, max> since we just added an element - // We need at least 2 elements to access previous - if ($lastKey > 0 && isset($values[$lastKey - 1][3])) { - // Concatenate the previous value with the current term - $values[$lastKey - 1][3] = $values[$lastKey - 1][3] . $term; - // Remove last element to avoid repetition - array_pop($values); - } - } - $last_node_type = $node_type; - } - - // Build multi-row INSERT with prepared statement - // Generate placeholders for all rows: (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), ... - $placeholders = array(); - $flatParams = array(); - foreach ($values as $row) { - $placeholders[] = "(?, ?, ?, ?, ?)"; - // Flatten the row values into a single array for binding - $flatParams[] = $row[0]; // TiSeID - $flatParams[] = $row[1]; // TiCount - $flatParams[] = $row[2]; // TiOrder - $flatParams[] = $row[3]; // TiText - $flatParams[] = $row[4]; // TiWordCount - } - - if (!empty($placeholders)) { - Connection::preparedExecute( - "INSERT INTO tempword_occurrences ( - TiSeID, TiCount, TiOrder, TiText, TiWordCount - ) VALUES " . implode(',', $placeholders), - $flatParams - ); - } - // Delete elements TiOrder=@order - Connection::preparedExecute( - "DELETE FROM tempword_occurrences WHERE TiOrder=?", - [$order] - ); - Connection::query( - "INSERT INTO temp_word_occurrences ( - TiCount, TiSeID, TiOrder, TiWordCount, TiText - ) - SELECT MIN(TiCount) s, TiSeID, TiOrder, TiWordCount, - group_concat(TiText ORDER BY TiCount SEPARATOR '') - FROM tempword_occurrences - GROUP BY TiOrder" - ); - Connection::execute("DROP TABLE tempword_occurrences"); + return self::buildTokensFromMecab($mecabed); } finally { if (file_exists($file_name)) { unlink($file_name); @@ -224,29 +112,97 @@ public static function parseJapaneseToDatabase(string $text, bool $useMaxSeID): } /** - * Parse a Japanese text using MeCab and add it to the database. - * - * @param string $text Text to parse. - * @param int $id Text ID. If $id = -1 print results, - * if $id = -2 return splitted texts + * Convert raw MeCab output into ParsedToken objects. * - * @return null|string[] Splitted sentence if $id = -2 + * Kept separate from the MeCab subprocess call so it can be unit-tested with + * captured/synthetic MeCab output. Mirrors the former SQL pipeline: build + * staging rows, drop the final EOP order group, then group rows sharing a + * TiOrder into one token (text concatenated in TiCount order). * - * @psalm-return non-empty-list|null + * @param string $mecabed Raw MeCab output ("word\ttype\tnode" per line) * - * @internal Use TextParsing::splitIntoSentences(), parseAndDisplayPreview(), or parseAndSave() instead. + * @return ParsedToken[] */ - public static function parseJapanese(string $text, int $id): ?array + public static function buildTokensFromMecab(string $mecabed): array { - if ($id == -2) { - return self::splitJapaneseSentences($text); + /** @var array $values */ + $values = array(); + $order = 0; + $sid = 1; + $term_type = 0; + $last_node_type = 0; + $count = 0; + $separator = mb_chr(9); + if ($separator === false) { + $separator = "\t"; } + foreach (explode(PHP_EOL, $mecabed) as $line) { + if (trim($line) == "") { + continue; + } + $parts = explode($separator, $line); + $term = $parts[0] ?? ''; + $node_type = $parts[1] ?? ''; + $third = $parts[2] ?? ''; + if ($term_type == 2 || $term == 'EOP' && $third == '7') { + $sid += 1; + } + $tiSeID = $sid; + $tiCount = $count + 1; + $count += mb_strlen($term); + $last_term_type = $term_type; + if ($third == '7') { + if ($term == 'EOP') { + $term = '¶'; + } + $term_type = 2; + } elseif (in_array($node_type, ['2', '6', '7', '8'])) { + $term_type = 0; + } else { + $term_type = 1; + } - if ($id == -1) { - self::displayJapanesePreview($text); + // Increase word order: + // Once if the current or the previous term were words + // Twice if current or the previous were not of unmanaged type + $order += (int)($term_type == 0 && $last_term_type == 0) + + (int)($term_type != 1 || $last_term_type != 1); + $tiWordCount = $term_type == 0 ? 1 : 0; + $values[] = array($tiSeID, $tiCount, $order, $term, $tiWordCount); + + // Special case for kazu (numbers): merge consecutive number nodes. + if ($last_node_type == 8 && $node_type == 8) { + $lastKey = array_key_last($values); + if ($lastKey > 0 && isset($values[$lastKey - 1][3])) { + $values[$lastKey - 1][3] = $values[$lastKey - 1][3] . $term; + array_pop($values); + } + } + $last_node_type = $node_type; } - self::parseJapaneseToDatabase($text, $id > 0); - return null; + // Group staging rows by TiOrder, dropping the final EOP order group. + // (This replaces "DELETE WHERE TiOrder=@order" + "GROUP BY TiOrder".) + /** @var array> $groups */ + $groups = array(); + foreach ($values as $r) { + if ($r[2] === $order) { + continue; + } + $groups[$r[2]][] = $r; + } + ksort($groups); + + $tokens = array(); + foreach ($groups as $ord => $rows) { + // Concatenate merged node text in TiCount order. + usort($rows, fn(array $a, array $b): int => $a[1] <=> $b[1]); + $text = ''; + foreach ($rows as $rr) { + $text .= $rr[3]; + } + $tokens[] = new ParsedToken($rows[0][0], $ord, $rows[0][4], $text); + } + return $tokens; } } diff --git a/src/Shared/Infrastructure/Database/ParsedToken.php b/src/Shared/Infrastructure/Database/ParsedToken.php new file mode 100644 index 000000000..a2d55b6a7 --- /dev/null +++ b/src/Shared/Infrastructure/Database/ParsedToken.php @@ -0,0 +1,64 @@ + + * @license Unlicense + * @link https://hugofara.github.io/lwt/developer/api + * @since 3.2.2 + */ + +declare(strict_types=1); + +namespace Lwt\Shared\Infrastructure\Database; + +/** + * One token produced while parsing a text: a word or a non-word run + * (whitespace / punctuation). + * + * This replaces the rows that used to be written to the `temp_word_occurrences` + * scratch table during parsing. Tokens are produced fully in PHP and consumed + * by TokenPersistence, so no scratch table is needed. See ScratchTables for the + * history of why the old temp tables were removed. + * + * Field meanings mirror the former temp_word_occurrences columns: + * - $sentence: 1-based sentence index within this text (was TiSeID, but local, + * not a pre-computed SeID). + * - $order: global monotonic token order across the whole text (was TiOrder). + * - $wordCount: 1 for a word token, 0 for a non-word run (was TiWordCount). + * - $text: the token text (was TiText). + * + * @since 3.2.2 + */ +final class ParsedToken +{ + /** + * @param int $sentence 1-based sentence index within the text + * @param int $order Global monotonic token order across the text + * @param int $wordCount 1 for a word, 0 for a non-word run + * @param string $text The token text + */ + public function __construct( + public readonly int $sentence, + public readonly int $order, + public readonly int $wordCount, + public readonly string $text + ) { + } + + /** + * Whether this token is a word (as opposed to whitespace/punctuation). + * + * @return bool + */ + public function isWord(): bool + { + return $this->wordCount !== 0; + } +} diff --git a/src/Shared/Infrastructure/Database/ScratchTables.php b/src/Shared/Infrastructure/Database/ScratchTables.php new file mode 100644 index 000000000..28ae11ba5 --- /dev/null +++ b/src/Shared/Infrastructure/Database/ScratchTables.php @@ -0,0 +1,75 @@ + + * @license Unlicense + * @link https://hugofara.github.io/lwt/developer/api + * @since 3.2.2 + */ + +declare(strict_types=1); + +namespace Lwt\Shared\Infrastructure\Database; + +/** + * Creates the per-connection scratch table used while importing vocabulary. + * + * `temp_words` used to be a persistent, globally-shared InnoDB table that every + * import `TRUNCATE`d and refilled. That design caused two problems: + * + * - **Tablespace crashes.** On file-per-table InnoDB, `TRUNCATE` physically + * drops and recreates the `.ibd`. When that file went missing (notably on + * Windows) the table was left with a missing tablespace and every subsequent + * import failed with InnoDB error 194 "Tablespace is missing for a table" + * (see issue #247). + * - **Concurrency corruption.** Because the table was shared, two imports + * running at once (or any two users in multi-user mode) read and truncated + * each other's rows. + * + * It is now created per database connection with `CREATE TEMPORARY TABLE`, so + * each request gets its own private copy. Temporary InnoDB tables live in the + * shared session temporary tablespace (there is no per-table `.ibd` to orphan), + * and they are dropped automatically when the connection closes. + * + * Callers must `ensureWords()` before use, then clear rows with `DELETE FROM` — + * never `TRUNCATE`, which is DDL that recreates the tablespace and implicitly + * commits any open transaction. + * + * (Text parsing no longer uses any scratch table — see TokenPersistence.) + * + * @since 3.2.2 + */ +final class ScratchTables +{ + /** + * Ensure the word-import staging table exists for this connection. + * + * Holds the rows of a vocabulary import file before they are merged into + * the `words` table. + * + * @return void + */ + public static function ensureWords(): void + { + Connection::query( + <<<'SQL' + CREATE TEMPORARY TABLE IF NOT EXISTS temp_words ( + WoText varchar(250) DEFAULT NULL, + WoTextLC varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + WoTranslation varchar(500) NOT NULL DEFAULT '*', + WoRomanization varchar(100) DEFAULT NULL, + WoSentence varchar(1000) DEFAULT NULL, + WoTaglist varchar(255) DEFAULT NULL, + PRIMARY KEY(WoTextLC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + SQL + ); + } +} diff --git a/src/Shared/Infrastructure/Database/StandardTextParser.php b/src/Shared/Infrastructure/Database/StandardTextParser.php index 4d2904697..e7486f94a 100644 --- a/src/Shared/Infrastructure/Database/StandardTextParser.php +++ b/src/Shared/Infrastructure/Database/StandardTextParser.php @@ -189,23 +189,21 @@ public static function displayStandardPreview(string $text, bool $rtlScript): vo } /** - * Parse standard text and insert into temp_word_occurrences. + * Build the tab-delimited token blob from preprocessed text. * - * @param string $text Preprocessed text + * Produces one line per token as "\t", where a term + * ending in "\r" marks the end of a sentence. This is the same + * serialization the old LOAD DATA path consumed; parseBlob() turns it + * into ParsedToken objects. + * + * @param string $text Text after word-splitting transformations * @param string $termchar Word character regex * @param string $removeSpaces Space removal setting - * @param bool $useMaxSeID Whether to query for max sentence ID - * - * @return void * - * @psalm-suppress MixedArgument + * @return string */ - public static function parseStandardToDatabase( - string $text, - string $termchar, - string $removeSpaces, - bool $useMaxSeID - ): void { + private static function buildTokenBlob(string $text, string $termchar, string $removeSpaces): string + { $qc = self::quoteChars(); $replaced = preg_replace( array( @@ -225,136 +223,120 @@ public static function parseStandardToDatabase( str_replace(array("\t", "\n\n"), array("\n", ""), $text) ); $text = trim($replaced ?? $text); - $text = StringUtils::removeSpaces( + return StringUtils::removeSpaces( preg_replace("/(\n|^)(?!1\t)/u", "\n0\t", $text) ?? $text, $removeSpaces ); + } - // It is faster to write to a file and let SQL do its magic, but may run into - // security restrictions - $use_local_infile = in_array( - Connection::fetchValue("SELECT @@GLOBAL.local_infile as value"), - array(1, '1', 'ON') - ); - // For database mode, we use a positive ID placeholder (1) since saveWithSql - // only checks if id > 0 for sentence ID calculation - $idForSql = $useMaxSeID ? 1 : 0; - if ($use_local_infile) { - TextParsingPersistence::saveWithSql($text, $idForSql); - } else { - $order = 0; - $sid = 1; - if ($useMaxSeID) { - // Get next auto-increment value from table status - // This is more reliable than MAX(SeID)+1 when there are gaps - $dbname = Globals::getDatabaseName(); - $sentencesTable = Globals::table('sentences'); - $sid = (int)Connection::preparedFetchValue( - "SELECT AUTO_INCREMENT as value FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", - [$dbname, $sentencesTable] - ); - // Fall back to MAX+1 if AUTO_INCREMENT is not available - if ($sid <= 0) { - $sid = (int)Connection::fetchValue( - "SELECT IFNULL(MAX(`SeID`)+1,1) as value FROM sentences" - . UserScopedQuery::forTable('sentences') - ); - } + /** + * Turn the token blob into ParsedToken objects. + * + * Replicates the semantics of the former LOAD DATA `SET` clause: each line + * is "\t"; a term ending with "\r" ends the current + * sentence (that token still belongs to the current sentence, the next one + * starts a new sentence). Order is a global 1-based counter. + * + * Unlike the old saveWithSqlFallback(), this does NOT trim the line, so the + * "\r" sentence markers and trailing-space tokens are preserved (that bug + * caused LOAD-DATA-less installs to parse every text as one sentence). + * + * @param string $blob Token blob from buildTokenBlob() + * + * @return ParsedToken[] + */ + private static function parseBlob(string $blob): array + { + $tokens = []; + $sentence = 1; + $order = 0; + foreach (explode("\n", $blob) as $line) { + $tab = strpos($line, "\t"); + if ($tab === false) { + // Blank or malformed line (e.g. a stray empty line): no token. + continue; } - $count = 0; - $rows = array(); - foreach (explode("\n", $text) as $line) { - if (trim($line) == "") { - continue; - } - list($word_count, $term) = explode("\t", $line); - $tiSeID = $sid; // TiSeID - $tiCount = $count + 1; // TiCount - $count += mb_strlen($term); - if (str_ends_with($term, "\r")) { - $term = str_replace("\r", '', $term); - $sid++; - $count = 0; - } - $tiOrder = ++$order; // TiOrder - $tiWordCount = (int)$word_count; // TiWordCount - $rows[] = array($tiSeID, $tiCount, $tiOrder, $term, $tiWordCount); + $wordCount = (int) substr($line, 0, $tab); + $term = substr($line, $tab + 1); + $sentenceEnd = str_ends_with($term, "\r"); + if ($sentenceEnd) { + $term = str_replace("\r", '', $term); } - - // Build multi-row INSERT with prepared statement - if (!empty($rows)) { - $placeholders = array(); - $flatParams = array(); - foreach ($rows as $row) { - $placeholders[] = "(?, ?, ?, ?, ?)"; - $flatParams = array_merge($flatParams, $row); - } - - Connection::preparedExecute( - "INSERT INTO temp_word_occurrences ( - TiSeID, TiCount, TiOrder, TiText, TiWordCount - ) VALUES " . implode(',', $placeholders), - $flatParams - ); + $order++; + $tokens[] = new ParsedToken($sentence, $order, $wordCount, $term); + if ($sentenceEnd) { + $sentence++; } } + return $tokens; } /** - * Parse a text using the default tools. It is a not-japanese text. - * - * @param string $text Text to parse - * @param int $id Text ID. If $id == -2, only split the text. - * @param int $lid Language ID. - * - * @return null|string[] If $id == -2 return a splitted version of the text. + * Tokenize a (character-substituted) standard text into ParsedToken objects. * - * @psalm-return non-empty-list|null + * @param string $text Preprocessed text (character substitutions applied) + * @param int $lid Language ID * - * @internal Use TextParsing::splitIntoSentences(), parseAndDisplayPreview(), or parseAndSave() instead. + * @return ParsedToken[] */ - public static function parseStandard(string $text, int $id, int $lid): ?array + public static function tokenize(string $text, int $lid): array { $settings = self::getLanguageSettings($lid); - - // Return null if language not found if ($settings === null) { - return null; + return []; } - - // Apply initial transformations (paragraph markers, trim, collapse spaces) - $text = self::applyInitialTransformations( + $text = self::applyInitialTransformations($text, $settings['splitEachChar']); + $text = self::applyWordSplitting( $text, - $settings['splitEachChar'] + $settings['splitSentence'], + $settings['noSentenceEnd'], + $settings['termchar'] ); + $blob = self::buildTokenBlob($text, $settings['termchar'], $settings['removeSpaces']); + return self::parseBlob($blob); + } - // Preview mode - display HTML BEFORE word splitting - if ($id == -1) { - self::displayStandardPreview($text, (bool)$settings['rtlScript']); + /** + * Split a (character-substituted) standard text into sentences only. + * + * @param string $text Preprocessed text (character substitutions applied) + * @param int $lid Language ID + * + * @return string[] + * + * @psalm-return non-empty-list + */ + public static function splitSentences(string $text, int $lid): array + { + $settings = self::getLanguageSettings($lid); + if ($settings === null) { + return ['']; } - - // Apply word-splitting transformations + $text = self::applyInitialTransformations($text, $settings['splitEachChar']); $text = self::applyWordSplitting( $text, $settings['splitSentence'], $settings['noSentenceEnd'], $settings['termchar'] ); + return self::splitStandardSentences($text, $settings['removeSpaces']); + } - // Split-only mode - if ($id == -2) { - return self::splitStandardSentences($text, $settings['removeSpaces']); + /** + * Echo the preview HTML for a (character-substituted) standard text. + * + * @param string $text Preprocessed text (character substitutions applied) + * @param int $lid Language ID + * + * @return void + */ + public static function echoPreview(string $text, int $lid): void + { + $settings = self::getLanguageSettings($lid); + if ($settings === null) { + return; } - - // Database insertion (for both preview mode -1 and actual save mode > 0) - self::parseStandardToDatabase( - $text, - $settings['termchar'], - $settings['removeSpaces'], - $id > 0 - ); - - return null; + $text = self::applyInitialTransformations($text, $settings['splitEachChar']); + self::displayStandardPreview($text, (bool)$settings['rtlScript']); } } diff --git a/src/Shared/Infrastructure/Database/TextParsing.php b/src/Shared/Infrastructure/Database/TextParsing.php index 1accbe363..5cb5f3819 100644 --- a/src/Shared/Infrastructure/Database/TextParsing.php +++ b/src/Shared/Infrastructure/Database/TextParsing.php @@ -23,7 +23,9 @@ /** * Text parsing and processing utilities (facade). * - * Delegates to JapaneseTextParser, StandardTextParser, and TextParsingPersistence. + * Delegates tokenization to JapaneseTextParser / StandardTextParser and + * persistence to TokenPersistence. Parsing happens entirely in PHP — there are + * no scratch tables involved. * * @since 3.0.0 */ @@ -32,9 +34,6 @@ class TextParsing /** * Split text into sentences without database operations. * - * Use this method when you only need to split text into sentences - * without saving to the database (e.g., for long text splitting). - * * @param string $text Text to parse * @param int $lid Language ID * @@ -44,15 +43,21 @@ class TextParsing */ public static function splitIntoSentences(string $text, int $lid): array { - $result = self::prepare($text, -2, $lid); - return $result ?? ['']; + $pre = self::preprocess($text, $lid); + if ($pre === null) { + return ['']; + } + [$ptext, $isMecab] = $pre; + if ($isMecab) { + return JapaneseTextParser::splitJapaneseSentences($ptext); + } + return StandardTextParser::splitSentences($ptext, $lid); } /** * Parse text and display preview HTML for validation. * - * Use this method for the text checking UI. Outputs HTML directly - * to show parsed sentences and word statistics. + * Outputs HTML directly to show parsed sentences and word statistics. * * @param string $text Text to parse * @param int $lid Language ID @@ -71,33 +76,28 @@ public static function parseAndDisplayPreview(string $text, int $lid): void } $rtlScript = (bool)$record['LgRightToLeft']; - // Parse text and display preview HTML (id=-1 triggers preview display in prepare) - self::prepare($text, -1, $lid); - - // Display sentences and word statistics - TextParsingPersistence::checkValid($lid); - - // Get multi-word expressions - $wl = TextParsingPersistence::getMultiWordLengths($lid); - - // Process multi-word expressions if any exist - if (!empty($wl)) { - TextParsingPersistence::checkExpressions($wl); + $pre = self::preprocess($text, $lid); + if ($pre === null) { + return; + } + [$ptext, $isMecab] = $pre; + + // Preview HTML is shown before word splitting. + if ($isMecab) { + JapaneseTextParser::displayJapanesePreview($ptext); + $tokens = JapaneseTextParser::tokenize($ptext); + } else { + StandardTextParser::echoPreview($ptext, $lid); + $tokens = StandardTextParser::tokenize($ptext, $lid); } - // Display statistics - TextParsingPersistence::displayStatistics($lid, $rtlScript, !empty($wl)); - - // Clean up - QueryBuilder::table('temp_word_occurrences')->truncate(); + TokenPersistence::echoCheckValid($tokens, $lid); + TokenPersistence::echoStatistics($tokens, $lid, $rtlScript); } /** * Parse text and save to database. * - * Use this method when creating or updating texts. Parses the text - * and inserts sentences and text items into the database. - * * @param string $text Text to parse * @param int $lid Language ID * @param int $textId Text ID (must be positive) @@ -123,28 +123,13 @@ public static function parseAndSave(string $text, int $lid, int $textId): void throw DatabaseException::recordNotFound('languages', 'LgID', $lid); } - // Parse text into temp_word_occurrences (id>0 uses MAX(SeID)+1 for sentence IDs) - self::prepare($text, $textId, $lid); - - // Get multi-word expressions - $wl = TextParsingPersistence::getMultiWordLengths($lid); - - // Process multi-word expressions if any exist - if (!empty($wl)) { - TextParsingPersistence::checkExpressions($wl); - } - - // Register sentences and text items in database - TextParsingPersistence::registerSentencesTextItems($textId, $lid, !empty($wl)); - - // Clean up - QueryBuilder::table('temp_word_occurrences')->truncate(); + $tokens = self::tokenize($text, $lid); + TokenPersistence::save($tokens, $lid, $textId); } /** * Check/preview text and return parsing statistics without saving. * - * Use this method to get text statistics for preview purposes. * Does not output any HTML or save to database. * * @param string $text Text to parse @@ -154,100 +139,45 @@ public static function parseAndSave(string $text, int $lid, int $textId): void */ public static function checkText(string $text, int $lid): array { - $settings = StandardTextParser::getLanguageSettings($lid); - - if ($settings === null) { - return [ - 'sentences' => 0, - 'words' => 0, - 'unknownPercent' => 100.0, - 'preview' => '' - ]; - } - - // Prepare text into temp_word_occurrences - self::prepare($text, -1, $lid); - - // Get sentence count - $sentences = Connection::fetchAll( - 'SELECT GROUP_CONCAT(TiText ORDER BY TiOrder SEPARATOR "") - AS Sent FROM temp_word_occurrences GROUP BY TiSeID' - ); - $sentenceCount = count($sentences); - - // Build preview from first few sentences - $preview = ''; - $previewSentences = array_slice($sentences, 0, 3); - foreach ($previewSentences as $record) { - if ($preview !== '') { - $preview .= ' '; - } - $preview .= (string) ($record['Sent'] ?? ''); - } - if (count($sentences) > 3) { - $preview .= '...'; - } - - // Get word statistics - $bindings = [$lid]; - $rows = Connection::preparedFetchAll( - "SELECT COUNT(`TiOrder`) AS cnt, IF(0=TiWordCount,0,1) AS len, - LOWER(TiText) AS word, WoTranslation - FROM temp_word_occurrences - LEFT JOIN words ON LOWER(TiText)=WoTextLC AND WoLgID=?" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " GROUP BY LOWER(TiText)", - $bindings - ); - - $totalWords = 0; - $unknownWords = 0; - - foreach ($rows as $record) { - if ($record['len'] == 1) { - $totalWords += (int) $record['cnt']; - // Word is unknown if it has no translation - if (empty($record['WoTranslation'])) { - $unknownWords += (int) $record['cnt']; - } - } - } - - $unknownPercent = $totalWords > 0 - ? round(($unknownWords / $totalWords) * 100, 1) - : 100.0; - - // Clean up temp_word_occurrences - QueryBuilder::table('temp_word_occurrences')->truncate(); - - return [ - 'sentences' => $sentenceCount, - 'words' => $totalWords, - 'unknownPercent' => $unknownPercent, - 'preview' => $preview - ]; + $tokens = self::tokenize($text, $lid); + return TokenPersistence::stats($tokens, $lid); } /** - * Pre-parse the input text before a definitive parsing by a specialized parser. + * Tokenize a text into ParsedToken objects (no database writes). * * @param string $text Text to parse - * @param int $id Text ID * @param int $lid Language ID * - * @return null|string[] If $id = -2 return a splitted version of the text + * @return ParsedToken[] + */ + private static function tokenize(string $text, int $lid): array + { + $pre = self::preprocess($text, $lid); + if ($pre === null) { + return []; + } + [$ptext, $isMecab] = $pre; + return $isMecab + ? JapaneseTextParser::tokenize($ptext) + : StandardTextParser::tokenize($ptext, $lid); + } + + /** + * Apply the language's text preprocessing (escaping + character + * substitutions) and report whether it uses the MeCab parser. * - * @psalm-return non-empty-list|null + * @param string $text Raw text + * @param int $lid Language ID * - * @internal Use splitIntoSentences(), parseAndDisplayPreview(), or parseAndSave() instead. + * @return array{0: string, 1: bool}|null [preprocessed text, isMecab] or null if language missing */ - private static function prepare(string $text, int $id, int $lid): ?array + private static function preprocess(string $text, int $lid): ?array { $record = QueryBuilder::table('languages') ->where('LgID', '=', $lid) ->firstPrepared(); - // Return null if language not found if ($record === null) { return null; } @@ -255,7 +185,6 @@ private static function prepare(string $text, int $id, int $lid): ?array $termchar = (string)$record['LgRegexpWordCharacters']; $replace = explode("|", (string) $record['LgCharacterSubstitutions']); $text = Escaping::prepareTextdata($text); - QueryBuilder::table('temp_word_occurrences')->truncate(); // because of sentence special characters $text = str_replace(array('}', '{'), array(']', '['), $text); @@ -266,9 +195,6 @@ private static function prepare(string $text, int $id, int $lid): ?array } } - if ('MECAB' == strtoupper(trim($termchar))) { - return JapaneseTextParser::parseJapanese($text, $id); - } - return StandardTextParser::parseStandard($text, $id, $lid); + return [$text, 'MECAB' === strtoupper(trim($termchar))]; } } diff --git a/src/Shared/Infrastructure/Database/TextParsingPersistence.php b/src/Shared/Infrastructure/Database/TextParsingPersistence.php deleted file mode 100644 index 9d6fe0156..000000000 --- a/src/Shared/Infrastructure/Database/TextParsingPersistence.php +++ /dev/null @@ -1,518 +0,0 @@ - - * @license Unlicense - * @link https://hugofara.github.io/lwt/developer/api - * @since 3.0.0 - */ - -declare(strict_types=1); - -namespace Lwt\Shared\Infrastructure\Database; - -use Lwt\Shared\Infrastructure\Globals; - -/** - * Shared database operations for text parsing. - * - * Handles SQL-based text saving, sentence/text-item registration, - * statistics display, and multi-word expression checking. - * - * @since 3.0.0 - */ -class TextParsingPersistence -{ - /** - * Insert a processed text in the data in pure SQL way. - * - * @param string $text Preprocessed text to insert - * @param int $id Text ID - * - * @return void - */ - public static function saveWithSql(string $text, int $id): void - { - $file_name = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "tmpti.txt"; - $fp = fopen($file_name, 'w'); - if ($fp !== false) { - fwrite($fp, $text); - fclose($fp); - } - Connection::query("SET @order=0, @sid=1, @count = 0;"); - if ($id > 0) { - // Get next auto-increment value for accurate TiSeID calculation - $dbname = Globals::getDatabaseName(); - $sentencesTable = Globals::table('sentences'); - $autoInc = (int)Connection::preparedFetchValue( - "SELECT AUTO_INCREMENT as value FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", - [$dbname, $sentencesTable] - ); - // Fall back to MAX+1 if AUTO_INCREMENT is not available - if ($autoInc <= 0) { - $autoInc = (int)Connection::fetchValue( - "SELECT IFNULL(MAX(`SeID`)+1,1) as value FROM sentences" - . UserScopedQuery::forTable('sentences') - ); - } - Connection::query("SET @sid = $autoInc;"); - } - // LOAD DATA LOCAL INFILE does not support prepared statements for file path - // We need to use Connection::query() here, but we escape the file path manually - $connection = Globals::getDbConnection(); - if ($connection === null) { - throw new \RuntimeException('Database connection not available'); - } - $escaped_file_name = (string) mysqli_real_escape_string($connection, $file_name); - $sql = "LOAD DATA LOCAL INFILE '$escaped_file_name' - INTO TABLE temp_word_occurrences - FIELDS TERMINATED BY '\\t' LINES TERMINATED BY '\\n' (@word_count, @term) - SET - TiSeID = @sid, - TiCount = (@count:=@count+CHAR_LENGTH(@term))+1-CHAR_LENGTH(@term), - TiOrder = IF( - @term LIKE '%\\r', - CASE - WHEN (@term:=REPLACE(@term,'\\r','')) IS NULL THEN NULL - WHEN (@sid:=@sid+1) IS NULL THEN NULL - WHEN @count:= 0 IS NULL THEN NULL - ELSE @order := @order+1 - END, - @order := @order+1 - ), - TiText = @term, - TiWordCount = @word_count"; - - // Try LOAD DATA LOCAL INFILE, fall back to INSERT if it fails - try { - Connection::query($sql); - } catch (\RuntimeException $e) { - // If LOAD DATA LOCAL INFILE is disabled, use fallback method - if (strpos($e->getMessage(), 'LOAD DATA LOCAL INFILE is forbidden') !== false) { - self::saveWithSqlFallback($text, $id); - } else { - throw $e; - } - } - unlink($file_name); - } - - /** - * Fallback method to insert text data when LOAD DATA LOCAL INFILE is disabled. - * - * @param string $text Preprocessed text to insert - * @param int $id Text ID - * - * @return void - */ - public static function saveWithSqlFallback(string $text, int $id): void - { - // Get starting sentence ID - $sid = 1; - if ($id > 0) { - // Get next auto-increment value for accurate TiSeID calculation - $dbname = Globals::getDatabaseName(); - $sentencesTable = Globals::table('sentences'); - $sid = (int)Connection::preparedFetchValue( - "SELECT AUTO_INCREMENT as value FROM information_schema.TABLES - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", - [$dbname, $sentencesTable] - ); - // Fall back to MAX+1 if AUTO_INCREMENT is not available - if ($sid <= 0) { - $sid = (int)Connection::fetchValue( - "SELECT IFNULL(MAX(`SeID`)+1,1) as value FROM sentences" - . UserScopedQuery::forTable('sentences') - ); - } - } - - $lines = explode("\n", $text); - $order = 0; - $count = 0; - - foreach ($lines as $line) { - $line = trim($line); - if ($line === '') { - continue; - } - - $parts = explode("\t", $line); - if (count($parts) < 2) { - continue; - } - - $word_count = (int)$parts[0]; - $term = $parts[1]; - - // Handle line breaks (increase sentence ID) - if (substr($term, -1) === "\r") { - $term = rtrim($term, "\r"); - $order++; - $count = 0; - $sid++; - } else { - $order++; - } - - $current_count = $count; - $count += strlen($term) + 1; - - Connection::preparedExecute( - "INSERT INTO temp_word_occurrences - (TiSeID, TiCount, TiOrder, TiText, TiWordCount) - VALUES (?, ?, ?, ?, ?)", - [$sid, $current_count, $order, $term, $word_count] - ); - } - } - - /** - * Echo the sentences in a text. Prepare JS data for words and word count. - * - * @param int $lid Language ID - * - * @return void - */ - public static function checkValid(int $lid): void - { - $wo = $nw = array(); - $sentences = Connection::fetchAll( - 'SELECT GROUP_CONCAT(TiText order by TiOrder SEPARATOR "") - Sent FROM temp_word_occurrences group by TiSeID' - ); - echo '

Sentences

    '; - foreach ($sentences as $record) { - echo "
  1. " . \htmlspecialchars((string) ($record['Sent'] ?? ''), ENT_QUOTES, 'UTF-8') . "
  2. "; - } - echo '
'; - $bindings = [$lid]; - $rows = Connection::preparedFetchAll( - "SELECT count(`TiOrder`) cnt, if(0=TiWordCount,0,1) as len, - LOWER(TiText) as word, WoTranslation - FROM temp_word_occurrences - LEFT JOIN words ON lower(TiText)=WoTextLC AND WoLgID=?" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " GROUP BY lower(TiText)", - $bindings - ); - foreach ($rows as $record) { - if ($record['len'] == 1) { - $wo[] = array( - \htmlspecialchars((string) ($record['word'] ?? ''), ENT_QUOTES, 'UTF-8'), - $record['cnt'], - \htmlspecialchars((string) ($record['WoTranslation'] ?? ''), ENT_QUOTES, 'UTF-8') - ); - } else { - $nw[] = array( - htmlspecialchars((string)($record['word'] ?? ''), ENT_QUOTES, 'UTF-8'), - htmlspecialchars((string)($record['cnt'] ?? ''), ENT_QUOTES, 'UTF-8') - ); - } - } - // JavaScript moved to src/frontend/js/texts/text_check_display.ts - echo ''; - } - - /** - * Append sentences and text items in the database. - * - * TiSeID in temp_word_occurrences is pre-computed to match future SeID values. - * When parseStandardToDatabase runs with useMaxSeID=true, it sets TiSeID - * to MAX(SeID)+1, MAX(SeID)+2, etc. When we insert sentences here, they - * get those exact SeID values via auto-increment, so TiSeID = SeID. - * - * @param int $tid ID of text from which insert data - * @param int $lid ID of the language of the text - * @param bool $hasmultiword Set to true to insert multi-words as well. - * - * @return void - */ - public static function registerSentencesTextItems(int $tid, int $lid, bool $hasmultiword): void - { - // STEP 1: Insert sentences FIRST to satisfy FK constraint. - Connection::query('SET @i=0;'); - Connection::preparedExecute( - "INSERT INTO sentences ( - SeLgID, SeTxID, SeOrder, SeFirstPos, SeText - ) SELECT - ?, - ?, - @i:=@i+1, - MIN(IF(TiWordCount=0, TiOrder+1, TiOrder)), - GROUP_CONCAT(TiText ORDER BY TiOrder SEPARATOR \"\") - FROM temp_word_occurrences - GROUP BY TiSeID - ORDER BY TiSeID", - [$lid, $tid] - ); - - // STEP 1.5: Align TiSeID with actual SeID values. - // The pre-computed TiSeID may not match actual AUTO_INCREMENT values, - // so we update temp_word_occurrences to use the actual SeID from inserted sentences. - // ROW_NUMBER() maps TiSeID rank to SeOrder, which we JOIN to get SeID. - Connection::preparedExecute( - "UPDATE temp_word_occurrences t - JOIN ( - SELECT TiSeID AS old_seid, - ROW_NUMBER() OVER (ORDER BY TiSeID) AS rn - FROM temp_word_occurrences - GROUP BY TiSeID - ) mapping ON t.TiSeID = mapping.old_seid - JOIN sentences s ON s.SeOrder = mapping.rn AND s.SeTxID = ? - SET t.TiSeID = s.SeID", - [$tid] - ); - - // STEP 1.6: Also update tempexprs.sent if multiword expressions exist. - // tempexprs was populated before sentences were inserted, so its `sent` - // field also needs to be aligned with actual SeID values. - if ($hasmultiword) { - Connection::preparedExecute( - "UPDATE tempexprs e - JOIN ( - SELECT sent AS old_sent, - ROW_NUMBER() OVER (ORDER BY sent) AS rn - FROM tempexprs - WHERE sent IS NOT NULL - GROUP BY sent - ) mapping ON e.sent = mapping.old_sent - JOIN sentences s ON s.SeOrder = mapping.rn AND s.SeTxID = ? - SET e.sent = s.SeID", - [$tid] - ); - } - - // STEP 2: Insert text items. TiSeID and tempexprs.sent now equal actual SeID. - if ($hasmultiword) { - // Build SQL and bindings in lockstep. Each forTablePrepared() call - // both appends " AND WoUsID = ?" to the SQL and pushes $userId - // onto $bindings, so the bindings stay in left-to-right placeholder - // order even with two user-scope injections inside the UNION. - $bindings = [$lid, $tid]; - $sql = "INSERT INTO word_occurrences ( - Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text - ) SELECT WoID, ?, ?, sent, TiOrder - (2*(n-1)) TiOrder, - n TiWordCount, word - FROM tempexprs - JOIN words - ON WoTextLC = lword AND WoWordCount = n" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " WHERE lword IS NOT NULL AND WoLgID = ?"; - $bindings[] = $lid; - $bindings[] = $lid; - $bindings[] = $tid; - $sql .= " UNION ALL - SELECT WoID, ?, ?, TiSeID, TiOrder, TiWordCount, TiText - FROM temp_word_occurrences - LEFT JOIN words - ON LOWER(TiText) = WoTextLC AND TiWordCount=1 AND WoLgID = ?"; - $bindings[] = $lid; - $sql .= UserScopedQuery::forTablePrepared('words', $bindings, '') - . " ORDER BY TiOrder, TiWordCount"; - - $stmt = Connection::prepare($sql); - $stmt->bindValues($bindings); - $stmt->execute(); - } else { - $bindings = [$lid, $tid, $lid]; - Connection::preparedExecute( - "INSERT INTO word_occurrences ( - Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text - ) - SELECT WoID, ?, ?, TiSeID, TiOrder, TiWordCount, TiText - FROM temp_word_occurrences - LEFT JOIN words - ON LOWER(TiText) = WoTextLC AND TiWordCount=1 AND WoLgID = ?" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " ORDER BY TiOrder, TiWordCount", - $bindings - ); - } - } - - /** - * Display statistics about a text. - * - * @param int $lid Language ID - * @param bool $rtlScript true if language is right-to-left - * @param bool $multiwords Display if text has multi-words - * - * @return void - */ - public static function displayStatistics(int $lid, bool $rtlScript, bool $multiwords): void - { - $mw = array(); - if ($multiwords) { - $bindings = [$lid]; - $rows = Connection::preparedFetchAll( - "SELECT COUNT(WoID) cnt, n as len, - LOWER(WoText) AS word, WoTranslation - FROM tempexprs - JOIN words - ON WoTextLC = lword AND WoWordCount = n" - . UserScopedQuery::forTablePrepared('words', $bindings, '') - . " WHERE lword IS NOT NULL AND WoLgID = ? - GROUP BY WoID ORDER BY WoTextLC", - $bindings - ); - foreach ($rows as $record) { - $mw[] = array( - htmlspecialchars((string)($record['word'] ?? ''), ENT_QUOTES, 'UTF-8'), - $record['cnt'], - htmlspecialchars((string)($record['WoTranslation'] ?? ''), ENT_QUOTES, 'UTF-8') - ); - } - } - // JavaScript moved to src/frontend/js/texts/text_check_display.ts - echo ''; - } - - /** - * Get all multi-word expression lengths for a language. - * - * @param int $lid Language ID - * - * @return int[] Array of distinct word counts (e.g., [2, 3] for 2-word and 3-word expressions) - */ - public static function getMultiWordLengths(int $lid): array - { - $wl = []; - $rows = QueryBuilder::table('words') - ->select(['DISTINCT(WoWordCount) as WoWordCount']) - ->where('WoLgID', '=', $lid) - ->where('WoWordCount', '>', 1) - ->getPrepared(); - - foreach ($rows as $record) { - $wl[] = (int)$record['WoWordCount']; - } - - return $wl; - } - - /** - * Check a language that contains expressions. - * - * @param int[] $wl All the different expression length in the language. - * - * @return void - */ - public static function checkExpressions(array $wl): void - { - $wl_max = 0; - $mw_sql = ''; - foreach ($wl as $word_length) { - if ($wl_max < $word_length) { - $wl_max = $word_length; - } - $mw_sql .= ' WHEN ' . $word_length . - ' THEN @a' . ($word_length * 2 - 1); - } - $set_wo_sql = $set_wo_sql_2 = $del_wo_sql = $init_var = ''; - // For all possible multi-words length - for ($i = $wl_max * 2 - 1; $i > 1; $i--) { - $set_wo_sql .= "WHEN (@a$i := @a" . ($i - 1) . ") IS NULL THEN NULL "; - $set_wo_sql_2 .= "WHEN (@a$i := @a" . ($i - 2) . ") IS NULL THEN NULL "; - $del_wo_sql .= "WHEN (@a$i := @a0) IS NULL THEN NULL "; - $init_var .= "@a$i=0,"; - } - // 2.8.1-fork: @a0 is always 0? @f always '' but necessary to force code execution - Connection::query( - "SET $init_var@a1=0, @a0=0, @se_id=0, @c='', @d=0, @f='', @ti_or=0;" - ); - // Create a table to store length of each terms - Connection::query( - "CREATE TEMPORARY TABLE IF NOT EXISTS numbers( - n tinyint(3) unsigned NOT NULL - );" - ); - Connection::execute("TRUNCATE TABLE numbers"); - Connection::query( - "INSERT IGNORE INTO numbers(n) VALUES (" . - implode('),(', $wl) . - ');' - ); - // Store garbage - Connection::query( - "CREATE TABLE IF NOT EXISTS tempexprs ( - sent mediumint unsigned, - word varchar(250), - lword varchar(250), - TiOrder smallint unsigned, - n tinyint(3) unsigned NOT NULL - )" - ); - Connection::execute("TRUNCATE TABLE tempexprs"); - Connection::query( - "INSERT IGNORE INTO tempexprs - (sent, word, lword, TiOrder, n) - -- 2.10.0-fork: straight_join may be irrelevant as the query is less skewed - SELECT straight_join - IF( - @se_id=TiSeID and @ti_or=TiOrder, - IF((@ti_or:=TiOrder+@a0) is null,TiSeID,TiSeID), - IF( - @se_id=TiSeID, - IF( - (@d=1) and (0<>TiWordCount), - CASE $set_wo_sql_2 - WHEN (@a1:=TiCount+@a0) IS NULL THEN NULL - WHEN (@se_id:=TiSeID+@a0) IS NULL THEN NULL - WHEN (@ti_or:=TiOrder+@a0) IS NULL THEN NULL - WHEN (@c:=concat(@c,TiText)) IS NULL THEN NULL - WHEN (@d:=(0<>TiWordCount)+@a0) IS NULL THEN NULL - ELSE TiSeID - END, - CASE $set_wo_sql - WHEN (@a1:=TiCount+@a0) IS NULL THEN NULL - WHEN (@se_id:=TiSeID+@a0) IS NULL THEN NULL - WHEN (@ti_or:=TiOrder+@a0) IS NULL THEN NULL - WHEN (@c:=concat(@c,TiText)) IS NULL THEN NULL - WHEN (@d:=(0<>TiWordCount)+@a0) IS NULL THEN NULL - ELSE TiSeID - END - ), - CASE $del_wo_sql - WHEN (@a1:=TiCount+@a0) IS NULL THEN NULL - WHEN (@se_id:=TiSeID+@a0) IS NULL THEN NULL - WHEN (@ti_or:=TiOrder+@a0) IS NULL THEN NULL - WHEN (@c:=concat(TiText,@f)) IS NULL THEN NULL - WHEN (@d:=(0<>TiWordCount)+@a0) IS NULL THEN NULL - ELSE TiSeID - END - ) - ) sent, - if( - @d=0, - NULL, - if( - CRC32(@z:=substr(@c,CASE n$mw_sql END))<>CRC32(LOWER(@z)), - @z, - '' - ) - ) word, - if(@d=0 or ''=@z, NULL, lower(@z)) lword, - TiOrder, - n - FROM numbers , temp_word_occurrences" - ); - } -} diff --git a/src/Shared/Infrastructure/Database/TokenPersistence.php b/src/Shared/Infrastructure/Database/TokenPersistence.php new file mode 100644 index 000000000..d4f7daf0e --- /dev/null +++ b/src/Shared/Infrastructure/Database/TokenPersistence.php @@ -0,0 +1,495 @@ + + * @license Unlicense + * @link https://hugofara.github.io/lwt/developer/api + * @since 3.2.2 + */ + +declare(strict_types=1); + +namespace Lwt\Shared\Infrastructure\Database; + +/** + * Turns a parsed token stream into `sentences` and `word_occurrences` rows, + * detecting multi-word expressions along the way — all in PHP. + * + * This replaces the old scratch-table pipeline (temp_word_occurrences + + * tempexprs + numbers + the LOAD DATA path + the stateful @-variable multi-word + * detection SQL). Sentences are inserted first (to satisfy the FK on + * word_occurrences), their real SeIDs are read back, and word occurrences — + * single words and multi-word expressions — are inserted referencing them. + * + * @since 3.2.2 + */ +final class TokenPersistence +{ + /** @var int Rows per INSERT statement. */ + private const CHUNK = 500; + + /** + * Save parsed tokens as sentences + word occurrences for a text. + * + * @param ParsedToken[] $tokens Tokens for the whole text + * @param int $lid Language ID + * @param int $textId Text ID + * + * @return void + */ + public static function save(array $tokens, int $lid, int $textId): void + { + if (empty($tokens)) { + return; + } + $bySentence = self::groupBySentence($tokens); + $seIdMap = self::insertSentences($bySentence, $lid, $textId); + + $singleMap = self::singleWordTerms($lid, self::distinctWordLowercase($tokens)); + $mwTerms = self::multiWordTerms($lid); + + // Single tokens (words + non-words): every token becomes an occurrence. + $rows = []; + foreach ($tokens as $t) { + $woId = null; + if ($t->wordCount === 1) { + $woId = $singleMap[self::lc($t->text)]['id'] ?? null; + } + $rows[] = [$woId, $lid, $textId, $seIdMap[$t->sentence], $t->order, $t->wordCount, $t->text]; + } + // Multi-word expressions overlay the single words they span. + foreach (self::detectMultiWords($bySentence, $mwTerms) as $mw) { + // Store the span text only when it differs from its lowercase form + // (matches the historical Ti2Text storage optimisation). + $stored = $mw['text'] !== self::lc($mw['text']) ? $mw['text'] : ''; + $rows[] = [$mw['id'], $lid, $textId, $seIdMap[$mw['sentence']], $mw['order'], $mw['n'], $stored]; + } + + self::insertWordOccurrences($rows); + } + + /** + * Compute preview statistics for the check-text UI (no output). + * + * @param ParsedToken[] $tokens Tokens for the whole text + * @param int $lid Language ID + * + * @return array{sentences: int, words: int, unknownPercent: float, preview: string} + */ + public static function stats(array $tokens, int $lid): array + { + if (empty($tokens)) { + return ['sentences' => 0, 'words' => 0, 'unknownPercent' => 100.0, 'preview' => '']; + } + $bySentence = self::groupBySentence($tokens); + + $counts = []; + $total = 0; + foreach ($tokens as $t) { + if ($t->wordCount === 1) { + $lc = self::lc($t->text); + $counts[$lc] = ($counts[$lc] ?? 0) + 1; + $total++; + } + } + $single = self::singleWordTerms($lid, array_keys($counts)); + $unknown = 0; + foreach ($counts as $lc => $cnt) { + if (empty($single[$lc]['tr'] ?? '')) { + $unknown += $cnt; + } + } + $unknownPercent = $total > 0 ? round($unknown / $total * 100, 1) : 100.0; + + $texts = []; + foreach ($bySentence as $sTokens) { + $texts[] = self::sentenceText($sTokens); + } + $preview = implode(' ', array_slice($texts, 0, 3)); + if (count($texts) > 3) { + $preview .= '...'; + } + + return [ + 'sentences' => count($bySentence), + 'words' => $total, + 'unknownPercent' => $unknownPercent, + 'preview' => $preview, + ]; + } + + /** + * Echo the sentence list and per-word JSON for the check-text preview. + * + * @param ParsedToken[] $tokens Tokens for the whole text + * @param int $lid Language ID + * + * @return void + */ + public static function echoCheckValid(array $tokens, int $lid): void + { + $bySentence = self::groupBySentence($tokens); + echo '

Sentences

    '; + foreach ($bySentence as $sTokens) { + echo '
  1. ' . \htmlspecialchars(self::sentenceText($sTokens), ENT_QUOTES, 'UTF-8') . '
  2. '; + } + echo '
'; + + $wordCounts = []; + $nonWordCounts = []; + foreach ($tokens as $t) { + $lc = self::lc($t->text); + if ($t->wordCount === 1) { + $wordCounts[$lc] = ($wordCounts[$lc] ?? 0) + 1; + } else { + $nonWordCounts[$lc] = ($nonWordCounts[$lc] ?? 0) + 1; + } + } + $single = self::singleWordTerms($lid, array_keys($wordCounts)); + $wo = []; + foreach ($wordCounts as $lc => $cnt) { + $wo[] = [ + \htmlspecialchars($lc, ENT_QUOTES, 'UTF-8'), + $cnt, + \htmlspecialchars($single[$lc]['tr'] ?? '', ENT_QUOTES, 'UTF-8'), + ]; + } + $nw = []; + foreach ($nonWordCounts as $lc => $cnt) { + $nw[] = [ + \htmlspecialchars($lc, ENT_QUOTES, 'UTF-8'), + \htmlspecialchars((string)$cnt, ENT_QUOTES, 'UTF-8'), + ]; + } + echo ''; + } + + /** + * Echo the multi-word statistics JSON for the check-text preview. + * + * @param ParsedToken[] $tokens Tokens for the whole text + * @param int $lid Language ID + * @param bool $rtlScript Whether the language is right-to-left + * + * @return void + */ + public static function echoStatistics(array $tokens, int $lid, bool $rtlScript): void + { + $mwTerms = self::multiWordTerms($lid); + $occ = self::detectMultiWords(self::groupBySentence($tokens), $mwTerms); + + $idInfo = []; + foreach ($mwTerms as $terms) { + foreach ($terms as $info) { + $idInfo[$info['id']] = $info; + } + } + $byWo = []; + foreach ($occ as $o) { + $byWo[$o['id']] = ($byWo[$o['id']] ?? 0) + 1; + } + $mw = []; + foreach ($byWo as $id => $cnt) { + $info = $idInfo[$id] ?? ['text' => '', 'tr' => '']; + $mw[] = [ + \htmlspecialchars(self::lc($info['text']), ENT_QUOTES, 'UTF-8'), + $cnt, + \htmlspecialchars($info['tr'], ENT_QUOTES, 'UTF-8'), + ]; + } + \usort($mw, fn(array $a, array $b): int => \strcmp((string)$a[0], (string)$b[0])); + + echo ''; + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + /** + * Group tokens by their sentence index, preserving order. + * + * @param ParsedToken[] $tokens Tokens + * + * @return array> + */ + private static function groupBySentence(array $tokens): array + { + $by = []; + foreach ($tokens as $t) { + $by[$t->sentence][] = $t; + } + ksort($by); + return $by; + } + + /** + * Concatenate a sentence's token texts. + * + * @param list $sTokens Sentence tokens + * + * @return string + */ + private static function sentenceText(array $sTokens): string + { + $s = ''; + foreach ($sTokens as $t) { + $s .= $t->text; + } + return $s; + } + + /** + * Insert sentences and return a map of local sentence index -> real SeID. + * + * @param array> $bySentence Tokens grouped by sentence + * @param int $lid Language ID + * @param int $textId Text ID + * + * @return array + */ + private static function insertSentences(array $bySentence, int $lid, int $textId): array + { + $localSids = array_keys($bySentence); + $params = []; + $seOrder = 0; + foreach ($bySentence as $sTokens) { + $seOrder++; + $firstPos = null; + $text = ''; + foreach ($sTokens as $t) { + $pos = $t->wordCount === 0 ? $t->order + 1 : $t->order; + if ($firstPos === null || $pos < $firstPos) { + $firstPos = $pos; + } + $text .= $t->text; + } + array_push($params, $lid, $textId, $seOrder, (int)$firstPos, $text); + } + self::chunkedInsert( + 'INSERT INTO sentences (SeLgID, SeTxID, SeOrder, SeFirstPos, SeText) VALUES ', + '(?, ?, ?, ?, ?)', + 5, + $params + ); + + $bindings = [$textId]; + $rows = Connection::preparedFetchAll( + 'SELECT SeID FROM sentences WHERE SeTxID = ?' + . UserScopedQuery::forTablePrepared('sentences', $bindings) + . ' ORDER BY SeOrder', + $bindings + ); + $map = []; + foreach ($localSids as $i => $localSid) { + $map[$localSid] = (int)($rows[$i]['SeID'] ?? 0); + } + return $map; + } + + /** + * Insert word-occurrence rows in chunks. + * + * @param list $rows Rows + * + * @return void + */ + private static function insertWordOccurrences(array $rows): void + { + if (empty($rows)) { + return; + } + $params = []; + foreach ($rows as $r) { + array_push($params, $r[0], $r[1], $r[2], $r[3], $r[4], $r[5], $r[6]); + } + self::chunkedInsert( + 'INSERT INTO word_occurrences ' + . '(Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text) VALUES ', + '(?, ?, ?, ?, ?, ?, ?)', + 7, + $params + ); + } + + /** + * Execute a multi-row INSERT in chunks of CHUNK rows. + * + * @param string $prefix SQL up to and including "VALUES " + * @param string $rowPlaceholder Placeholder for one row, e.g. "(?, ?)" + * @param int $colsPerRow Number of columns per row + * @param list $params Flat parameter list + * + * @return void + */ + private static function chunkedInsert( + string $prefix, + string $rowPlaceholder, + int $colsPerRow, + array $params + ): void { + $rowCount = intdiv(count($params), $colsPerRow); + for ($i = 0; $i < $rowCount; $i += self::CHUNK) { + $n = min(self::CHUNK, $rowCount - $i); + $placeholders = implode(',', array_fill(0, $n, $rowPlaceholder)); + $slice = array_slice($params, $i * $colsPerRow, $n * $colsPerRow); + Connection::preparedExecute($prefix . $placeholders, $slice); + } + } + + /** + * Distinct lowercased word-token texts. + * + * @param ParsedToken[] $tokens Tokens + * + * @return list + */ + private static function distinctWordLowercase(array $tokens): array + { + $set = []; + foreach ($tokens as $t) { + if ($t->wordCount === 1) { + $set[self::lc($t->text)] = true; + } + } + return array_keys($set); + } + + /** + * Load single-word terms for the given lowercased words. + * + * @param int $lid Language ID + * @param list $lowerWords Distinct lowercased words to look up + * + * @return array + */ + private static function singleWordTerms(int $lid, array $lowerWords): array + { + if (empty($lowerWords)) { + return []; + } + $bindings = [$lid]; + // Build the IN clause manually: these are string keys (WoTextLC), not + // the integer IDs that Connection::buildPreparedInClause() expects. + $inClause = '(' . implode(',', array_fill(0, count($lowerWords), '?')) . ')'; + foreach ($lowerWords as $word) { + $bindings[] = $word; + } + $sql = 'SELECT WoID, WoTextLC, WoTranslation FROM words ' + . 'WHERE WoLgID = ? AND WoWordCount = 1 AND WoTextLC IN ' . $inClause + . UserScopedQuery::forTablePrepared('words', $bindings); + $map = []; + foreach (Connection::preparedFetchAll($sql, $bindings) as $r) { + $map[(string)$r['WoTextLC']] = [ + 'id' => (int)$r['WoID'], + 'tr' => (string)($r['WoTranslation'] ?? ''), + ]; + } + return $map; + } + + /** + * Load multi-word terms grouped by word count. + * + * @param int $lid Language ID + * + * @return array> + */ + private static function multiWordTerms(int $lid): array + { + $bindings = [$lid]; + $sql = 'SELECT WoID, WoText, WoTextLC, WoTranslation, WoWordCount FROM words ' + . 'WHERE WoLgID = ? AND WoWordCount > 1' + . UserScopedQuery::forTablePrepared('words', $bindings); + $map = []; + foreach (Connection::preparedFetchAll($sql, $bindings) as $r) { + $n = (int)$r['WoWordCount']; + $map[$n][(string)$r['WoTextLC']] = [ + 'id' => (int)$r['WoID'], + 'text' => (string)$r['WoText'], + 'tr' => (string)($r['WoTranslation'] ?? ''), + ]; + } + return $map; + } + + /** + * Detect multi-word expression occurrences in the token stream. + * + * For each sentence and each known multi-word length n, slide a window of n + * words and match the concatenated span (words + intervening separators) + * against the language's n-word terms. + * + * @param array> $bySentence Tokens by sentence + * @param array> $mwTerms Multi-word terms + * + * @return list + */ + private static function detectMultiWords(array $bySentence, array $mwTerms): array + { + if (empty($mwTerms)) { + return []; + } + $lengths = array_keys($mwTerms); + $occ = []; + foreach ($bySentence as $localSid => $sTokens) { + $wordIdx = []; + foreach ($sTokens as $idx => $t) { + if ($t->wordCount === 1) { + $wordIdx[] = $idx; + } + } + $wordCount = count($wordIdx); + foreach ($lengths as $n) { + if ($n < 2) { + continue; + } + for ($i = 0; $i + $n - 1 < $wordCount; $i++) { + $firstIdx = $wordIdx[$i]; + $lastIdx = $wordIdx[$i + $n - 1]; + $span = ''; + for ($k = $firstIdx; $k <= $lastIdx; $k++) { + $span .= $sTokens[$k]->text; + } + $lc = self::lc($span); + if (isset($mwTerms[$n][$lc])) { + $occ[] = [ + 'id' => $mwTerms[$n][$lc]['id'], + 'sentence' => $localSid, + 'order' => $sTokens[$firstIdx]->order, + 'n' => $n, + 'text' => $span, + ]; + } + } + } + } + return $occ; + } + + /** + * Lowercase a string (UTF-8). + * + * @param string $s Input + * + * @return string + */ + private static function lc(string $s): string + { + return mb_strtolower($s, 'UTF-8'); + } +} diff --git a/tests/backend/Shared/Infrastructure/Database/JapaneseTextParserTest.php b/tests/backend/Shared/Infrastructure/Database/JapaneseTextParserTest.php index 046bd224b..495bd80ec 100644 --- a/tests/backend/Shared/Infrastructure/Database/JapaneseTextParserTest.php +++ b/tests/backend/Shared/Infrastructure/Database/JapaneseTextParserTest.php @@ -128,34 +128,41 @@ public function splitJapaneseSentencesWithWhitespaceOnlyReturnsEmptyString(): vo } // ========================================================================= - // parseJapanese (split-only mode id=-2) + // buildTokensFromMecab (pure MeCab-output -> tokens, no binary needed) // ========================================================================= #[Test] - public function parseJapaneseWithIdMinus2ReturnsArrayOfSentences(): void + public function buildTokensFromMecabProducesExpectedTokens(): void { - $result = JapaneseTextParser::parseJapanese('Some text', -2); + // Captured MeCab output for "東京は大きいです。" in the parser's + // "-F %m\t%t\t%h" format, terminated with the EOP sentence marker. + $mecabed = "東京\t2\t46\nは\t6\t16\n大きい\t2\t10\n" + . "です\t6\t25\n。\t3\t7\nEOP\t3\t7\n"; - $this->assertIsArray($result); - $this->assertNotEmpty($result); - } + $tokens = JapaneseTextParser::buildTokensFromMecab($mecabed); - #[Test] - public function parseJapaneseWithIdMinus2MatchesSplitMethod(): void - { - $text = "Line A\nLine B"; - $splitResult = JapaneseTextParser::splitJapaneseSentences($text); - $parseResult = JapaneseTextParser::parseJapanese($text, -2); - - $this->assertSame($splitResult, $parseResult); + $this->assertCount(5, $tokens); + $this->assertSame( + ['東京', 'は', '大きい', 'です', '。'], + array_map(fn($t) => $t->text, $tokens) + ); + // Punctuation is a non-word; the rest are words. + $this->assertSame([1, 1, 1, 1, 0], array_map(fn($t) => $t->wordCount, $tokens)); + // All in the first sentence (the trailing EOP order group is dropped). + foreach ($tokens as $t) { + $this->assertSame(1, $t->sentence); + } + // Global order is strictly increasing. + $orders = array_map(fn($t) => $t->order, $tokens); + $sorted = $orders; + sort($sorted); + $this->assertSame($sorted, $orders); } #[Test] - public function parseJapaneseWithIdMinus2DoesNotReturnNull(): void + public function buildTokensFromMecabHandlesEmptyOutput(): void { - $result = JapaneseTextParser::parseJapanese('test', -2); - - $this->assertNotNull($result); + $this->assertSame([], JapaneseTextParser::buildTokensFromMecab('')); } // ========================================================================= @@ -204,32 +211,4 @@ public function displayJapanesePreviewCollapsesWhitespace(): void $this->assertStringContainsString('Word1 Word2', $output); } - - // ========================================================================= - // parseJapanese / parseJapaneseToDatabase (requires MeCab + DB) - // ========================================================================= - - #[Test] - public function parseJapaneseWithIdMinus1RequiresMecab(): void - { - $this->markTestSkipped( - 'MeCab installation required for display preview mode' - ); - } - - #[Test] - public function parseJapaneseWithPositiveIdRequiresMecabAndDatabase(): void - { - $this->markTestSkipped( - 'MeCab installation and database connection required for save mode' - ); - } - - #[Test] - public function parseJapaneseToDatabaseRequiresMecabAndDatabase(): void - { - $this->markTestSkipped( - 'MeCab installation and database connection required' - ); - } } diff --git a/tests/backend/Shared/Infrastructure/Database/StandardTextParserTest.php b/tests/backend/Shared/Infrastructure/Database/StandardTextParserTest.php index b8d0f4082..10cbf4069 100644 --- a/tests/backend/Shared/Infrastructure/Database/StandardTextParserTest.php +++ b/tests/backend/Shared/Infrastructure/Database/StandardTextParserTest.php @@ -288,20 +288,4 @@ public function getLanguageSettingsRequiresDatabase(): void { $this->markTestSkipped('Database connection required'); } - - // ========================================================================= - // parseStandard / parseStandardToDatabase (requires DB) - // ========================================================================= - - #[Test] - public function parseStandardRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - #[Test] - public function parseStandardToDatabaseRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } } diff --git a/tests/backend/Shared/Infrastructure/Database/TextParsingPersistenceTest.php b/tests/backend/Shared/Infrastructure/Database/TextParsingPersistenceTest.php deleted file mode 100644 index 7d50432fd..000000000 --- a/tests/backend/Shared/Infrastructure/Database/TextParsingPersistenceTest.php +++ /dev/null @@ -1,379 +0,0 @@ - - * @since 3.0.0 - */ - -declare(strict_types=1); - -namespace Tests\Backend\Shared\Infrastructure\Database; - -use Lwt\Shared\Infrastructure\Database\TextParsingPersistence; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -use PHPUnit\Framework\Attributes\CoversClass; - -/** - * Unit tests for TextParsingPersistence static methods. - * - * @since 3.0.0 - */ -#[CoversClass(TextParsingPersistence::class)] -class TextParsingPersistenceTest extends TestCase -{ - // ========================================================================= - // saveWithSql - signature and visibility - // ========================================================================= - - #[Test] - public function saveWithSqlIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSql' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function saveWithSqlAcceptsStringTextAndIntId(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSql' - ); - $params = $reflection->getParameters(); - - $this->assertCount(2, $params); - $this->assertSame('text', $params[0]->getName()); - $this->assertSame('string', $params[0]->getType()->getName()); - $this->assertSame('id', $params[1]->getName()); - $this->assertSame('int', $params[1]->getType()->getName()); - } - - #[Test] - public function saveWithSqlReturnsVoid(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSql' - ); - - $this->assertSame('void', $reflection->getReturnType()->getName()); - } - - #[Test] - public function saveWithSqlRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // saveWithSqlFallback - signature and visibility - // ========================================================================= - - #[Test] - public function saveWithSqlFallbackIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSqlFallback' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function saveWithSqlFallbackAcceptsStringTextAndIntId(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSqlFallback' - ); - $params = $reflection->getParameters(); - - $this->assertCount(2, $params); - $this->assertSame('text', $params[0]->getName()); - $this->assertSame('string', $params[0]->getType()->getName()); - $this->assertSame('id', $params[1]->getName()); - $this->assertSame('int', $params[1]->getType()->getName()); - } - - #[Test] - public function saveWithSqlFallbackReturnsVoid(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'saveWithSqlFallback' - ); - - $this->assertSame('void', $reflection->getReturnType()->getName()); - } - - #[Test] - public function saveWithSqlFallbackRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // checkValid - signature and visibility - // ========================================================================= - - #[Test] - public function checkValidIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkValid' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function checkValidAcceptsSingleIntParameter(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkValid' - ); - $params = $reflection->getParameters(); - - $this->assertCount(1, $params); - $this->assertSame('lid', $params[0]->getName()); - $this->assertSame('int', $params[0]->getType()->getName()); - } - - #[Test] - public function checkValidReturnsVoid(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkValid' - ); - - $this->assertSame('void', $reflection->getReturnType()->getName()); - } - - #[Test] - public function checkValidRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // registerSentencesTextItems - signature and visibility - // ========================================================================= - - #[Test] - public function registerSentencesTextItemsIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'registerSentencesTextItems' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function registerSentencesTextItemsAcceptsThreeParameters(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'registerSentencesTextItems' - ); - $params = $reflection->getParameters(); - - $this->assertCount(3, $params); - $this->assertSame('tid', $params[0]->getName()); - $this->assertSame('int', $params[0]->getType()->getName()); - $this->assertSame('lid', $params[1]->getName()); - $this->assertSame('int', $params[1]->getType()->getName()); - $this->assertSame('hasmultiword', $params[2]->getName()); - $this->assertSame('bool', $params[2]->getType()->getName()); - } - - #[Test] - public function registerSentencesTextItemsRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // getMultiWordLengths - signature and visibility - // ========================================================================= - - #[Test] - public function getMultiWordLengthsIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'getMultiWordLengths' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function getMultiWordLengthsAcceptsSingleIntParameter(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'getMultiWordLengths' - ); - $params = $reflection->getParameters(); - - $this->assertCount(1, $params); - $this->assertSame('lid', $params[0]->getName()); - $this->assertSame('int', $params[0]->getType()->getName()); - } - - #[Test] - public function getMultiWordLengthsReturnsArray(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'getMultiWordLengths' - ); - - $this->assertSame('array', $reflection->getReturnType()->getName()); - } - - #[Test] - public function getMultiWordLengthsRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // displayStatistics - signature and visibility - // ========================================================================= - - #[Test] - public function displayStatisticsIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'displayStatistics' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function displayStatisticsAcceptsThreeParameters(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'displayStatistics' - ); - $params = $reflection->getParameters(); - - $this->assertCount(3, $params); - $this->assertSame('lid', $params[0]->getName()); - $this->assertSame('int', $params[0]->getType()->getName()); - $this->assertSame('rtlScript', $params[1]->getName()); - $this->assertSame('bool', $params[1]->getType()->getName()); - $this->assertSame('multiwords', $params[2]->getName()); - $this->assertSame('bool', $params[2]->getType()->getName()); - } - - #[Test] - public function displayStatisticsRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // checkExpressions - signature and visibility - // ========================================================================= - - #[Test] - public function checkExpressionsIsPublicAndStatic(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkExpressions' - ); - - $this->assertTrue($reflection->isPublic()); - $this->assertTrue($reflection->isStatic()); - } - - #[Test] - public function checkExpressionsAcceptsSingleArrayParameter(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkExpressions' - ); - $params = $reflection->getParameters(); - - $this->assertCount(1, $params); - $this->assertSame('wl', $params[0]->getName()); - $this->assertSame('array', $params[0]->getType()->getName()); - } - - #[Test] - public function checkExpressionsReturnsVoid(): void - { - $reflection = new \ReflectionMethod( - TextParsingPersistence::class, - 'checkExpressions' - ); - - $this->assertSame('void', $reflection->getReturnType()->getName()); - } - - #[Test] - public function checkExpressionsRequiresDatabase(): void - { - $this->markTestSkipped('Database connection required'); - } - - // ========================================================================= - // Class-level checks - // ========================================================================= - - #[Test] - public function allPublicMethodsAreStatic(): void - { - $reflection = new \ReflectionClass(TextParsingPersistence::class); - $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); - - foreach ($methods as $method) { - $this->assertTrue( - $method->isStatic(), - "Method {$method->getName()} should be static" - ); - } - } - - #[Test] - public function classHasExpectedNumberOfPublicMethods(): void - { - $reflection = new \ReflectionClass(TextParsingPersistence::class); - $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); - - // saveWithSql, saveWithSqlFallback, checkValid, - // registerSentencesTextItems, displayStatistics, - // getMultiWordLengths, checkExpressions - $this->assertCount(7, $methods); - } -}