299 lines
9.0 KiB
PHP
299 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
class NoteContent
|
|
{
|
|
/**
|
|
* @return list<array{title: string, body: string, native_id: int|string|null}>
|
|
*/
|
|
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<array{title: string, body: string, native_id: int|string|null}>
|
|
*/
|
|
public static function normalizeStored(mixed $content): array
|
|
{
|
|
return self::fromList($content);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{title?: string, body?: string, native_id?: int|string|null}> $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<array{title?: string, body?: string}> $items
|
|
* @return list<string>
|
|
*/
|
|
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<string, mixed> $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];
|
|
}
|
|
}
|