*/ public static function fromList(mixed $list): array { if (! is_array($list)) { if ($list === null || $list === '') { return []; } $list = [$list]; } $items = []; foreach (array_values($list) as $raw) { $item = self::fromRaw($raw); if ($item !== null) { $items[] = $item; } } return $items; } /** * @return list */ public static function normalizeStored(mixed $content): array { return self::fromList($content); } /** * @param list $items */ public static function hash(array $items): string { $norm = []; foreach ($items as $item) { $norm[] = [ 't' => (string) ($item['title'] ?? ''), 'b' => (string) ($item['body'] ?? ''), 'i' => $item['native_id'] ?? null, ]; } return hash('sha256', (string) json_encode($norm, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } /** * @param list $items * @return list */ public static function titles(array $items, int $limit = 2): array { $out = []; foreach ($items as $item) { $title = trim((string) ($item['title'] ?? '')); if ($title === '') { $title = trim((string) preg_replace('/\s+/u', ' ', (string) ($item['body'] ?? ''))); $title = $title === '' ? '' : mb_substr($title, 0, 40); } if ($title === '') { continue; } $out[] = $title; if (count($out) >= $limit) { break; } } return $out; } /** * @return array{title: string, body: string, native_id: int|string|null}|null */ private static function fromRaw(mixed $raw): ?array { if (is_array($raw)) { if (self::looksStructured($raw)) { $title = trim((string) ($raw['title'] ?? '')); $body = $raw['body'] ?? $raw['snippet'] ?? $raw['text'] ?? $raw['content'] ?? ''; if (is_array($body)) { $body = (string) json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $body = self::cleanText((string) $body); if ($title === '') { [$title, $body] = self::splitTitleBody($body); } $nativeId = $raw['native_id'] ?? $raw['id'] ?? null; if (is_array($nativeId) || is_bool($nativeId)) { $nativeId = null; } return self::pack($title, $body, $nativeId); } $raw = (string) json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } elseif (! is_string($raw)) { $raw = $raw === null ? '' : (string) $raw; } $text = self::cleanText($raw); if ($text === '') { return null; } [$title, $body] = self::splitTitleBody($text); return self::pack($title, $body, null); } /** * @param array $raw */ private static function looksStructured(array $raw): bool { foreach (['title', 'body', 'snippet', 'native_id'] as $key) { if (array_key_exists($key, $raw)) { return true; } } return array_key_exists('id', $raw) && (array_key_exists('mod', $raw) || array_key_exists('text', $raw)); } /** * @return array{title: string, body: string, native_id: int|string|null}|null */ private static function pack(string $title, string $body, mixed $nativeId): ?array { $title = trim($title); $body = trim($body); if ($title === '' && $body === '') { return null; } if ($title === '') { $title = mb_substr(preg_replace('/\s+/u', ' ', $body) ?: $body, 0, 80); } if ($body === '') { $body = $title; } return [ 'title' => $title, 'body' => $body, 'native_id' => is_int($nativeId) || is_string($nativeId) ? $nativeId : null, ]; } public static function cleanText(string $text): string { $text = str_replace(["\r\n", "\r"], "\n", $text); $text = preg_replace('/\$[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}/u', '', $text) ?? $text; $text = preg_replace('/\b(?:com\.apple\.[A-Za-z0-9.]+|public\.(?:jpeg|jpg|png|heic|tiff|gif|image))\b/u', '', $text) ?? $text; $text = preg_replace('/\.(?:SFUI|PingFang|Helvetica|Menlo|Times|Avenir|Hiragino)[A-Za-z0-9-]*/u', '', $text) ?? $text; $kept = []; $inTail = false; foreach (explode("\n", $text) as $line) { if ($inTail) { continue; } $trim = trim($line); if ($trim === '') { $kept[] = ''; continue; } if (self::isAttributeJunkLine($trim)) { $inTail = true; continue; } $kept[] = self::stripTrailingSoup(rtrim($line)); } $text = implode("\n", $kept); $text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text; return trim($text); } private static function isJunkLine(string $line): bool { if (preg_match('/^[\s()\[\]*.,\\\\]+$/u', $line) === 1) { return true; } return mb_strlen($line) <= 8 && str_contains($line, '(') && preg_match('/^[\s()\[\]*A-Za-z0-9\\\\]+$/u', $line) === 1; } /** * Apple Notes protobuf / attributed-string runs dumped after the real text. */ private static function isAttributeJunkLine(string $line): bool { if (self::isJunkLine($line)) { return true; } if (preg_match('/^(?:com\.apple\.|public\.)/i', $line) === 1) { return true; } if (preg_match('/^\.[A-Za-z][A-Za-z0-9_-]+$/u', $line) === 1) { return true; } if (mb_strlen($line) <= 12 && preg_match('/h[\x{0300}-\x{036F}\x{0590}-\x{05FF}\x{2000}-\x{206F}\x{F000}-\x{FFFF}Ħħ]/u', $line) === 1) { return true; } if (mb_strlen($line) === 1 && preg_match('/^[\p{P}\p{S}+]$/u', $line) === 1) { return true; } if (mb_strlen($line) <= 10 && str_contains($line, '(') && preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) !== 1) { return true; } if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) !== 1 && str_starts_with($line, '(') && preg_match('/\p{L}{3,}\s+\p{L}{3,}/u', $line) !== 1) { return true; } return self::isAsciiSoupLine($line); } private static function isAsciiSoupLine(string $line): bool { if (preg_match('#https?://|www\.#i', $line) === 1) { return false; } if (preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}]/u', $line) === 1) { return false; } if (str_contains($line, "\u{FFFD}")) { return true; } if (preg_match('/[A-Z]{3,}[@?~}|{]{2,}|[{|][a-z]{4,}[0-9]{5,}/', $line) === 1) { return true; } $len = mb_strlen($line); if ($len < 16) { return false; } $spaces = preg_match_all('/\s/u', $line) ?: 0; $punct = preg_match_all('/[\p{P}\p{S}]/u', $line) ?: 0; return $spaces <= 2 && ($punct / $len) >= 0.22; } private static function stripTrailingSoup(string $line): string { $line = preg_replace('/\x{FFFD}.*$/u', '', $line) ?? $line; $line = preg_replace('/\s*\([^\n]{0,12}[A-Z]{3,}[A-Za-z0-9`\\\\\\]\\^\\[@?~}|{]{8,}.*$/u', '', $line) ?? $line; return rtrim($line); } /** * @return array{0: string, 1: string} */ private static function splitTitleBody(string $text): array { $lines = preg_split('/\n+/', $text) ?: []; $title = ''; $firstLine = ''; $rest = []; foreach ($lines as $line) { $line = trim($line); if ($line === '') { continue; } if ($title === '') { $title = mb_substr($line, 0, 80); $firstLine = $line; continue; } $rest[] = $line; } $body = trim(implode("\n", $rest)); if ($body === '') { $body = $firstLine; } return [$title, $body]; } }