dir = rtrim($dir, '/\\'); } public function warnings(): array { return array_values(array_unique($this->warnings)); } /** データのファイル(相対パス => 絶対パス) */ private function files(): array { if ($this->files !== null) return $this->files; $out = []; if (is_dir($this->dir)) { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $f) { if (!$f->isFile()) continue; $rel = str_replace('\\', '/', substr($f->getPathname(), strlen($this->dir) + 1)); $out[$rel] = $f->getPathname(); } } ksort($out, SORT_STRING); return $this->files = $out; } /** 全部の行を作る(データは小さいので、メモリーに) */ private function load(): array { if ($this->data !== null) return $this->data; $d = array_fill_keys(array_keys(Stores::tables()), []); $generic = []; foreach ($this->files() as $rel => $abs) { foreach (Stores::SKIP as $re) { if (preg_match($re, $rel)) { if (str_starts_with($rel, 'db/') && str_ends_with($rel, '.json')) $generic[$rel] = $abs; continue 2; } } $text = (string)@file_get_contents($abs); // SQLiteのデータベース(例:data/site.sqlite)とジャーナル if (str_starts_with($text, "SQLite format 3\0") || preg_match('/-(journal|wal|shm)$/', $rel)) { $this->warnings[] = "SQLiteのファイルは変換しません:{$rel}"; continue; } if (!$this->store($rel, $text, $d)) $d['lb_files'][] = [$rel, new Blob($text)]; } $this->data = ['stores' => $d, 'generic' => $generic]; return $this->data; } /** * ファイルを決まった形のテーブルにする * * @return bool falseならlb_files */ private function store(string $rel, string $text, array &$d): bool { $isJson = str_ends_with($rel, '.json'); $json = $isJson ? json_decode($text, true) : null; if ($isJson && json_last_error() !== JSON_ERROR_NONE) return false; if (preg_match('#^user/(\d+)\.([A-Za-z0-9]+)\.json$#', $rel, $m)) { if (!is_array($json)) return false; $role = $json['role'] ?? null; $d['lb_users'][] = [(int)$m[1], $m[2], isset($json['email']) && is_string($json['email']) ? $json['email'] : null, is_int($role) ? $role : null, $text]; return true; } if ($rel === 'comments/ap.json') { if (!is_array($json)) return false; foreach ($json as $k => $v) $d['lb_comment_ap'][] = [(string)$k, self::encode($v)]; return true; } if (preg_match('#^comments/(.+)\.json$#', $rel, $m) && Stores::safeThread($m[1])) { if (!is_array($json) || (isset($json['comments']) && !is_array($json['comments']))) return false; $comments = $json['comments'] ?? []; unset($json['comments']); $d['lb_comment_threads'][] = [$m[1], self::encode((object)$json)]; $seq = 0; foreach ($comments as $id => $c) $d['lb_comments'][] = [$m[1], (string)$id, $seq++, self::encode($c)]; return true; } if (preg_match('#^notifications/([^/]+)\.json$#', $rel, $m) && Stores::safeName($m[1])) { if (!is_array($json) || !array_is_list($json)) return false; foreach ($json as $i => $n) $d['lb_notifications'][] = [$m[1], $i, self::encode($n)]; return true; } if ($rel === 'cron/cron.json') { if (!is_array($json) || !array_is_list($json)) return false; foreach ($json as $i => $j) $d['lb_cron'][] = [$i, self::encode($j)]; return true; } if ($rel === 'followers.txt' || $rel === 'following.txt') { $t = $rel === 'followers.txt' ? 'lb_followers' : 'lb_following'; $lines = array_values(array_filter(array_map('trim', explode("\n", $text)), fn($l) => $l !== '')); foreach ($lines as $i => $l) $d[$t][] = [$i, $l]; return true; } if (preg_match('#^ap/notes/([^/]+)\.json$#', $rel, $m) && Stores::safeName($m[1])) { if (!is_array($json)) return false; $d['lb_ap_notes'][] = [$m[1], $text]; return true; } return false; } public static function encode(mixed $v): string { return (string)json_encode($v, Stores::JSON_FLAGS); } public function tables(): array { $data = $this->load(); $out = []; foreach (Stores::tables() as $name => $t) { if (!empty($data['stores'][$name])) $out[] = $t; } foreach ($data['generic'] as $rel => $abs) { $g = $this->generic($abs); if ($g === null) { $this->warnings[] = "読めないテーブルのファイル:{$rel}"; continue; } $out[] = $g['table']; } return $out; } /** data/db/{テーブル}.json */ private function generic(string $abs): ?array { static $cache = []; if (isset($cache[$abs])) return $cache[$abs]; $j = json_decode((string)@file_get_contents($abs), true); if (!is_array($j) || !isset($j['table'], $j['columns']) || !is_array($j['columns'])) return null; $t = ['name' => (string)$j['table'], 'columns' => $j['columns'], 'pk' => $j['pk'] ?? [], 'indexes' => $j['indexes'] ?? []]; foreach ($t['columns'] as $i => $c) { if (isset($c['default']['value']) && is_array($c['default']['value'])) $t['columns'][$i]['default']['value'] = self::decodeValue($c['default']['value']); } return $cache[$abs] = ['table' => $t, 'rows' => $j['rows'] ?? []]; } /** JSONの値({"$blob": base64}、{"$float": "NaN"}) */ public static function decodeValue(mixed $v): mixed { if (is_array($v)) { if (isset($v['$blob'])) return new Blob((string)base64_decode((string)$v['$blob'])); if (isset($v['$float'])) return match ($v['$float']) { 'NaN' => NAN, 'Infinity' => INF, default => -INF }; return json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } return $v; } public function maxLengths(array $t): array { $out = []; $this->rows([strtolower($t['name'])], function(string $name, array $rows) use ($t, &$out): void { foreach ($t['columns'] as $i => $c) { foreach ($rows as $r) { $v = $r[$i] ?? null; $len = $v instanceof Blob ? strlen($v->data) : (is_string($v) ? mb_strlen($v, 'UTF-8') : 0); $out[$c['name']] = max($out[$c['name']] ?? 0, $len); } } }); return $out; } public function rows(array $tables, callable $sink): void { $data = $this->load(); foreach ($data['stores'] as $name => $rows) { if (!in_array($name, $tables, true) || empty($rows)) continue; foreach (array_chunk($rows, 500) as $chunk) $sink($name, $chunk); } foreach ($data['generic'] as $abs) { $g = $this->generic($abs); if ($g === null || !in_array(strtolower($g['table']['name']), $tables, true)) continue; foreach (array_chunk($g['rows'], 500) as $chunk) { $sink($g['table']['name'], array_map(fn($r) => array_map([self::class, 'decodeValue'], (array)$r), $chunk)); } } } }