WIP X-plore 비교 5-1 ④ SQLite 보기 (읽기 전용, 관리자 지시 10/21): 파일 탐색기 길게 누르기 [SQLite 보기 (읽기 전용)] (.db · .sqlite · .sqlite3 · .db3, 원격은 임시로 받아서) - 테이블 고르기 · 행 수 · 200 행씩 [더 보기] · 칸을 누르면 전체 값 (BLOB 은 16진 앞 256 바이트) · NULL 흐리게. 새 패키지 · 네이티브 라이브러리 없이 파일 형식을 직접 읽음 (머리 · sqlite_schema · 테이블 b-tree · 넘친 페이지 · 레코드, UTF-8 · UTF-16) - 파일은 읽기 전용으로만 염. 못 하는 것: WITHOUT ROWID 테이블 (안내) · -wal 에만 있는 바뀐 것 · 시험 파일은 진짜 SQLite 로 만든 것 (tool/fixtures/make_sqlite_fixtures.py → test/fixtures/sqlite, 2000 행 · 넘친 페이지 · 한글 · 64비트 정수 · 잘린 파일) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>xplore_p3b
parent
3444d84f35
commit
dcc805c4b8
|
|
@ -54,6 +54,7 @@
|
|||
"# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# Exported from the JJ_MKVMaker in-app browser\n\n",
|
||||
"'사진' 모델로 바꿔 다시": "Switch to 'Photos' and redo",
|
||||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(Languages cannot be added because AI translation is unavailable on this device)",
|
||||
"(BLOB {0}바이트)": "(BLOB {0} bytes)",
|
||||
"({0}개 지움)": "(delete {0})",
|
||||
"(더 새 파일을 덮어씀)": "(overwrites a newer file)",
|
||||
"(덮어씀)": "(overwritten)",
|
||||
|
|
@ -298,6 +299,8 @@
|
|||
"SMB 서버 주소 · 공유 이름이 없습니다": "The SMB server address or share name is empty",
|
||||
"SMB 서버 추가": "Add SMB server",
|
||||
"SMB 에서 받는 중": "Downloading from SMB",
|
||||
"SQLite 보기 (읽기 전용)": "View SQLite (read-only)",
|
||||
"SQLite 파일이 아니거나 깨졌습니다": "Not an SQLite file, or it is damaged",
|
||||
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
|
||||
"SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "Open, copy and sync folders on SSH servers (NAS, Linux) in the File explorer and Rsync screens (tabs at the top). Passwords, private keys and key passphrases are kept only in this device's secure storage. The server's key fingerprint is checked on the first connection.",
|
||||
"Stable Diffusion v1.5": "Stable Diffusion v1.5",
|
||||
|
|
@ -358,6 +361,7 @@
|
|||
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "The Tor screen is open, so it cannot be cleared now. Close it and try again.",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
|
||||
"VLC 로 열기": "Open with VLC",
|
||||
"WITHOUT ROWID 테이블은 아직 볼 수 없습니다": "WITHOUT ROWID tables cannot be shown yet",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)",
|
||||
|
|
@ -625,6 +629,7 @@
|
|||
"{0}초 (기본)": "{0} s (default)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "It wasn't ready within {0} seconds.",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).",
|
||||
"{0}행": "{0} rows",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)",
|
||||
"· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· Downloads: yt-dlp, aria2 (http, https and HTTP trackers immediately; torrent UDP tracker and DHT names when set manually - switching off ↔ on applies from the next run)",
|
||||
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· Downloaded AI models (about {0}, must be downloaded again)",
|
||||
|
|
@ -1074,6 +1079,7 @@
|
|||
"대소문자 그대로": "Keep case",
|
||||
"대소문자 무시": "Ignore case",
|
||||
"더 보기": "More",
|
||||
"더 보기 ({0} / {1})": "Show more ({0} / {1})",
|
||||
"더 읽기": "Read more",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)",
|
||||
"더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)",
|
||||
|
|
@ -2234,6 +2240,7 @@
|
|||
"일본어": "Japanese",
|
||||
"일시정지": "Pause",
|
||||
"읽기 전용": "Read-only",
|
||||
"읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다": "Read-only - this app does not change the file. Changes still in the write-ahead log (-wal) may not be shown",
|
||||
"읽기 전용 · 보기와 받기만 됩니다": "Read-only · view and download only",
|
||||
"읽기 전용 공유입니다 (보기 · 받기만)": "This share is read-only (view and download only)",
|
||||
"읽기 전용 매체입니다": "Read-only media",
|
||||
|
|
@ -2687,6 +2694,7 @@
|
|||
"태국어": "Thai",
|
||||
"탭에 보입니다 · 비우면 주소": "Shown on the tab · address if empty",
|
||||
"터키어": "Turkish",
|
||||
"테이블이 없습니다": "No tables",
|
||||
"토": "Sat",
|
||||
"토렌트 · 마그넷 다운로드": "Torrent · magnet download",
|
||||
"통과": "Passed",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# JJ_MKVMaker アプリ内ブラウザーから書き出し\n\n",
|
||||
"'사진' 모델로 바꿔 다시": "「写真」モデルに変えてやり直す",
|
||||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(この端末では AI 翻訳が使えないため言語を追加できません)",
|
||||
"(BLOB {0}바이트)": "(BLOB {0} バイト)",
|
||||
"({0}개 지움)": "({0} 個を削除)",
|
||||
"(더 새 파일을 덮어씀)": "(新しいファイルを上書き)",
|
||||
"(덮어씀)": "(上書き)",
|
||||
|
|
@ -298,6 +299,8 @@
|
|||
"SMB 서버 주소 · 공유 이름이 없습니다": "SMB サーバーのアドレス・共有名がありません",
|
||||
"SMB 서버 추가": "SMB サーバーを追加",
|
||||
"SMB 에서 받는 중": "SMB から受信中",
|
||||
"SQLite 보기 (읽기 전용)": "SQLite を表示 (読み取り専用)",
|
||||
"SQLite 파일이 아니거나 깨졌습니다": "SQLite ファイルではないか、壊れています",
|
||||
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
|
||||
"SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "SSH サーバー (NAS・Linux) のフォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部のタブ)。パスワード・秘密鍵・鍵のパスフレーズはこの端末の安全なストレージにのみ保存します。初回接続時にサーバーの鍵のフィンガープリントを確認します。",
|
||||
"Stable Diffusion v1.5": "Stable Diffusion v1.5",
|
||||
|
|
@ -358,6 +361,7 @@
|
|||
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "Tor 画面が開いているため今は消去できません。閉じてからもう一度行ってください。",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
|
||||
"VLC 로 열기": "VLC で開く",
|
||||
"WITHOUT ROWID 테이블은 아직 볼 수 없습니다": "WITHOUT ROWID テーブルはまだ表示できません",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)",
|
||||
|
|
@ -625,6 +629,7 @@
|
|||
"{0}초 (기본)": "{0}秒 (既定)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。",
|
||||
"{0}행": "{0} 行",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)",
|
||||
"· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· ダウンロード: yt-dlp、aria2 (http・https・HTTP トラッカーはすぐ、トレントの UDP トラッカー・DHT の名前は手動指定のとき - オフ ↔ オンは次回の実行から)",
|
||||
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· ダウンロードした AI モデル (約 {0}、再ダウンロードが必要)",
|
||||
|
|
@ -1074,6 +1079,7 @@
|
|||
"대소문자 그대로": "大文字・小文字はそのまま",
|
||||
"대소문자 무시": "大文字・小文字を区別しない",
|
||||
"더 보기": "その他",
|
||||
"더 보기 ({0} / {1})": "さらに表示 ({0} / {1})",
|
||||
"더 읽기": "続きを読む",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
|
||||
"더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)",
|
||||
|
|
@ -2234,6 +2240,7 @@
|
|||
"일본어": "日本語",
|
||||
"일시정지": "一時停止",
|
||||
"읽기 전용": "読み取り専用",
|
||||
"읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다": "読み取り専用 - このアプリはファイルを変更しません。書き込み中の変更 (-wal) は表示されないことがあります",
|
||||
"읽기 전용 · 보기와 받기만 됩니다": "読み取り専用 · 表示とダウンロードのみ",
|
||||
"읽기 전용 공유입니다 (보기 · 받기만)": "読み取り専用の共有です (表示 · ダウンロードのみ)",
|
||||
"읽기 전용 매체입니다": "読み取り専用メディアです",
|
||||
|
|
@ -2687,6 +2694,7 @@
|
|||
"태국어": "タイ語",
|
||||
"탭에 보입니다 · 비우면 주소": "タブに表示 · 空ならアドレス",
|
||||
"터키어": "トルコ語",
|
||||
"테이블이 없습니다": "テーブルがありません",
|
||||
"토": "土",
|
||||
"토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード",
|
||||
"통과": "合格",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"# Netscape HTTP Cookie File\n# JJ_MKVMaker 앱 안 브라우저에서 내보냄\n\n": "# Netscape HTTP Cookie File\n# 从 JJ_MKVMaker 内置浏览器导出\n\n",
|
||||
"'사진' 모델로 바꿔 다시": "改用“照片”模型重新处理",
|
||||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(此设备无法使用 AI 翻译,因此无法添加语言)",
|
||||
"(BLOB {0}바이트)": "(BLOB {0} 字节)",
|
||||
"({0}개 지움)": "(删除 {0} 个)",
|
||||
"(더 새 파일을 덮어씀)": "(覆盖较新的文件)",
|
||||
"(덮어씀)": "(覆盖)",
|
||||
|
|
@ -298,6 +299,8 @@
|
|||
"SMB 서버 주소 · 공유 이름이 없습니다": "SMB 服务器地址或共享名称为空",
|
||||
"SMB 서버 추가": "添加 SMB 服务器",
|
||||
"SMB 에서 받는 중": "正在从 SMB 下载",
|
||||
"SQLite 보기 (읽기 전용)": "查看 SQLite (只读)",
|
||||
"SQLite 파일이 아니거나 깨졌습니다": "不是 SQLite 文件或已损坏",
|
||||
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
|
||||
"SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "在文件资源管理器和 Rsync 界面中打开、复制和同步 SSH 服务器 (NAS · Linux) 上的文件夹 (顶部标签)。密码、私钥和私钥密码仅保存在本设备的安全存储中。首次连接时会确认服务器的密钥指纹。",
|
||||
"Stable Diffusion v1.5": "Stable Diffusion v1.5",
|
||||
|
|
@ -358,6 +361,7 @@
|
|||
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "Tor 画面正在打开,现在无法清除。请关闭后再试。",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
|
||||
"VLC 로 열기": "用 VLC 打开",
|
||||
"WITHOUT ROWID 테이블은 아직 볼 수 없습니다": "暂时无法显示 WITHOUT ROWID 表",
|
||||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)",
|
||||
|
|
@ -625,6 +629,7 @@
|
|||
"{0}초 (기본)": "{0} 秒 (默认)",
|
||||
"{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。",
|
||||
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。",
|
||||
"{0}행": "{0} 行",
|
||||
"· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)",
|
||||
"· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· 下载: yt-dlp、aria2 (http · https · HTTP Tracker 立即生效,种子的 UDP Tracker · DHT 名称在手动指定时 - 关闭 ↔ 开启从下次运行起生效)",
|
||||
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· 已下载的 AI 模型 (约 {0},需要重新下载)",
|
||||
|
|
@ -1074,6 +1079,7 @@
|
|||
"대소문자 그대로": "保持大小写",
|
||||
"대소문자 무시": "忽略大小写",
|
||||
"더 보기": "更多",
|
||||
"더 보기 ({0} / {1})": "显示更多 ({0} / {1})",
|
||||
"더 읽기": "继续读取",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
|
||||
"더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)",
|
||||
|
|
@ -2234,6 +2240,7 @@
|
|||
"일본어": "日语",
|
||||
"일시정지": "暂停",
|
||||
"읽기 전용": "只读",
|
||||
"읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다": "只读 - 本应用不会更改文件。仍在预写日志 (-wal) 中的更改可能不会显示",
|
||||
"읽기 전용 · 보기와 받기만 됩니다": "只读 · 仅可查看和下载",
|
||||
"읽기 전용 공유입니다 (보기 · 받기만)": "这是只读共享 (仅可查看和下载)",
|
||||
"읽기 전용 매체입니다": "只读介质",
|
||||
|
|
@ -2687,6 +2694,7 @@
|
|||
"태국어": "泰语",
|
||||
"탭에 보입니다 · 비우면 주소": "显示在标签上 · 为空则用地址",
|
||||
"터키어": "土耳其语",
|
||||
"테이블이 없습니다": "没有表",
|
||||
"토": "六",
|
||||
"토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载",
|
||||
"통과": "通过",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 파일 읽기 전용 보기 - 새 패키지 · 네이티브 라이브러리 없이 파일 형식
|
||||
/// (sqlite.org/fileformat2.html) 을 직접 읽는다. 쓰지 않는다 (파일을 열 때도 읽기 전용).
|
||||
/// - 읽는 것: 머리 · sqlite_schema · 테이블 b-tree (안쪽 · 잎 페이지) · 넘친 페이지 · 레코드 (NULL · 정수 · 실수 · 글 · BLOB)
|
||||
/// - 못 읽는 것: WITHOUT ROWID 테이블 (인덱스 b-tree) · 쓰는 중인 WAL 파일의 내용 (-wal 에만 있는 바뀐 것은 보이지 않음)
|
||||
class SqliteFile {
|
||||
final RandomAccessFile _f;
|
||||
final int pageSize, usable, pageCount;
|
||||
|
||||
/// 1 UTF-8 · 2 UTF-16LE · 3 UTF-16BE
|
||||
final int encoding;
|
||||
|
||||
SqliteFile._(this._f, this.pageSize, this.usable, this.pageCount, this.encoding);
|
||||
|
||||
/// 열기 (읽기 전용). SQLite 파일이 아니면 [SqliteFormatException]
|
||||
static Future<SqliteFile> open(String path) async {
|
||||
final f = await File(path).open();
|
||||
try {
|
||||
final h = await f.read(100);
|
||||
if (h.length < 100 || ascii.decode(h.sublist(0, 15), allowInvalid: true) != 'SQLite format 3') {
|
||||
throw const SqliteFormatException('not sqlite');
|
||||
}
|
||||
var ps = (h[16] << 8) | h[17];
|
||||
if (ps == 1) ps = 65536;
|
||||
if (ps < 512 || ps & (ps - 1) != 0) throw const SqliteFormatException('page size');
|
||||
final len = await f.length();
|
||||
final enc = _be(h, 56, 4);
|
||||
return SqliteFile._(f, ps, ps - h[20], len ~/ ps, enc == 0 ? 1 : enc);
|
||||
} catch (_) {
|
||||
await f.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() => _f.close();
|
||||
|
||||
final _pages = <int, Uint8List>{};
|
||||
|
||||
Future<Uint8List> _page(int n) async {
|
||||
if (n < 1 || n > pageCount) throw SqliteFormatException('page $n');
|
||||
final hit = _pages[n];
|
||||
if (hit != null) return hit;
|
||||
await _f.setPosition((n - 1) * pageSize);
|
||||
final b = await _f.read(pageSize);
|
||||
if (_pages.length > 256) _pages.remove(_pages.keys.first);
|
||||
return _pages[n] = b;
|
||||
}
|
||||
|
||||
/// sqlite_schema 의 테이블 (이름 · 루트 페이지 · 열 이름)
|
||||
Future<List<SqliteTable>> tables() async {
|
||||
final out = <SqliteTable>[];
|
||||
await for (final r in _rows(1, const [])) {
|
||||
if (r.values.length < 5 || r.values[0] != 'table') continue;
|
||||
final name = '${r.values[1]}';
|
||||
final sql = '${r.values[4] ?? ''}';
|
||||
final root = r.values[3];
|
||||
if (root is! int) continue;
|
||||
out.add(SqliteTable(name, root, columnsOf(sql), withoutRowid: RegExp(r'without\s+rowid\s*$', caseSensitive: false).hasMatch(sql.trim())));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// [t] 의 행을 [offset] 부터 [limit] 개 (rowid 순)
|
||||
Future<List<SqliteRow>> rows(SqliteTable t, {int offset = 0, int limit = 200}) async {
|
||||
if (t.withoutRowid) throw const SqliteFormatException('without rowid');
|
||||
final out = <SqliteRow>[];
|
||||
var i = 0;
|
||||
await for (final r in _rows(t.rootPage, t.columns)) {
|
||||
if (i++ < offset) continue;
|
||||
out.add(r);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// [t] 의 행 수 (페이지를 모두 훑음)
|
||||
Future<int> count(SqliteTable t) async {
|
||||
if (t.withoutRowid) return 0;
|
||||
var n = 0;
|
||||
final seen = <int>{};
|
||||
Future<void> walk(int page) async {
|
||||
if (!seen.add(page)) throw const SqliteFormatException('loop');
|
||||
final b = await _page(page);
|
||||
final o = page == 1 ? 100 : 0;
|
||||
final cells = _be(b, o + 3, 2);
|
||||
if (b[o] == 0x0D) {
|
||||
n += cells;
|
||||
} else if (b[o] == 0x05) {
|
||||
for (var i = 0; i < cells; i++) {
|
||||
await walk(_be(b, _be(b, o + 12 + i * 2, 2), 4));
|
||||
}
|
||||
await walk(_be(b, o + 8, 4));
|
||||
}
|
||||
}
|
||||
|
||||
await walk(t.rootPage);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 테이블 b-tree 를 rowid 순으로 (안쪽 페이지는 왼쪽 자식들 → 오른쪽 끝 자식)
|
||||
Stream<SqliteRow> _rows(int root, List<String> columns) async* {
|
||||
final seen = <int>{};
|
||||
final stack = <int>[root];
|
||||
// 깊이 우선 - 차례를 지키려고 자식을 거꾸로 쌓는다
|
||||
while (stack.isNotEmpty) {
|
||||
final page = stack.removeLast();
|
||||
if (!seen.add(page)) throw const SqliteFormatException('loop');
|
||||
final b = await _page(page);
|
||||
final o = page == 1 ? 100 : 0;
|
||||
final type = b[o];
|
||||
final cells = _be(b, o + 3, 2);
|
||||
if (type == 0x05) {
|
||||
final kids = [for (var i = 0; i < cells; i++) _be(b, _be(b, o + 12 + i * 2, 2), 4), _be(b, o + 8, 4)];
|
||||
stack.addAll(kids.reversed);
|
||||
} else if (type == 0x0D) {
|
||||
for (var i = 0; i < cells; i++) {
|
||||
var p = _be(b, o + 8 + i * 2, 2);
|
||||
final (size, n1) = _varint(b, p);
|
||||
p += n1;
|
||||
final (rowid, n2) = _varint(b, p);
|
||||
p += n2;
|
||||
final payload = await _payload(b, p, size);
|
||||
final values = _record(payload);
|
||||
// INTEGER PRIMARY KEY 열은 레코드에 NULL 로 두고 rowid 를 쓴다
|
||||
final ipk = columns.indexWhere((c) => c.startsWith('\u0000'));
|
||||
if (ipk >= 0 && ipk < values.length && values[ipk] == null) values[ipk] = rowid;
|
||||
yield SqliteRow(rowid, values);
|
||||
}
|
||||
} else {
|
||||
throw SqliteFormatException('page type $type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 셀의 내용 - 이 페이지에 든 만큼 + 넘친 페이지들
|
||||
Future<Uint8List> _payload(Uint8List b, int p, int size) async {
|
||||
final u = usable;
|
||||
final x = u - 35;
|
||||
if (size <= x) return b.sublist(p, p + size);
|
||||
final m = ((u - 12) * 32 ~/ 255) - 23;
|
||||
final k = m + ((size - m) % (u - 4));
|
||||
final local = k <= x ? k : m;
|
||||
final out = BytesBuilder(copy: false)..add(b.sublist(p, p + local));
|
||||
var next = _be(b, p + local, 4);
|
||||
var left = size - local;
|
||||
final seen = <int>{};
|
||||
while (left > 0 && next != 0) {
|
||||
if (!seen.add(next)) throw const SqliteFormatException('overflow loop');
|
||||
final ob = await _page(next);
|
||||
final take = left < u - 4 ? left : u - 4;
|
||||
out.add(ob.sublist(4, 4 + take));
|
||||
left -= take;
|
||||
next = _be(ob, 0, 4);
|
||||
}
|
||||
return out.takeBytes();
|
||||
}
|
||||
|
||||
List<Object?> _record(Uint8List r) {
|
||||
final (hsize, n0) = _varint(r, 0);
|
||||
var hp = n0;
|
||||
var dp = hsize;
|
||||
final out = <Object?>[];
|
||||
while (hp < hsize) {
|
||||
final (t, n) = _varint(r, hp);
|
||||
hp += n;
|
||||
switch (t) {
|
||||
case 0:
|
||||
out.add(null);
|
||||
case >= 1 && <= 6:
|
||||
final len = const [0, 1, 2, 3, 4, 6, 8][t];
|
||||
out.add(_signed(r, dp, len));
|
||||
dp += len;
|
||||
case 7:
|
||||
out.add(ByteData.sublistView(r, dp, dp + 8).getFloat64(0));
|
||||
dp += 8;
|
||||
case 8:
|
||||
out.add(0);
|
||||
case 9:
|
||||
out.add(1);
|
||||
default:
|
||||
if (t >= 12 && t.isEven) {
|
||||
final len = (t - 12) ~/ 2;
|
||||
out.add(r.sublist(dp, dp + len));
|
||||
dp += len;
|
||||
} else if (t >= 13) {
|
||||
final len = (t - 13) ~/ 2;
|
||||
out.add(_text(r.sublist(dp, dp + len)));
|
||||
dp += len;
|
||||
} else {
|
||||
out.add(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
String _text(Uint8List b) {
|
||||
if (encoding == 1) return utf8.decode(b, allowMalformed: true);
|
||||
final le = encoding == 2;
|
||||
return String.fromCharCodes([for (var i = 0; i + 1 < b.length; i += 2) le ? b[i] | (b[i + 1] << 8) : (b[i] << 8) | b[i + 1]]);
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteTable {
|
||||
final String name;
|
||||
final int rootPage;
|
||||
|
||||
/// 열 이름 (INTEGER PRIMARY KEY 열은 앞에 \u0000 표시 - [displayColumns] 로 보임)
|
||||
final List<String> columns;
|
||||
final bool withoutRowid;
|
||||
const SqliteTable(this.name, this.rootPage, this.columns, {this.withoutRowid = false});
|
||||
|
||||
List<String> get displayColumns => [for (final c in columns) c.replaceFirst('\u0000', '')];
|
||||
}
|
||||
|
||||
class SqliteRow {
|
||||
final int rowid;
|
||||
final List<Object?> values;
|
||||
const SqliteRow(this.rowid, this.values);
|
||||
}
|
||||
|
||||
class SqliteFormatException implements Exception {
|
||||
final String message;
|
||||
const SqliteFormatException(this.message);
|
||||
@override
|
||||
String toString() => 'SqliteFormatException: $message';
|
||||
}
|
||||
|
||||
int _be(Uint8List b, int o, int n) {
|
||||
var v = 0;
|
||||
for (var i = 0; i < n; i++) {
|
||||
v = (v << 8) | b[o + i];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
int _signed(Uint8List b, int o, int n) {
|
||||
var v = _be(b, o, n);
|
||||
final bits = n * 8;
|
||||
if (bits < 64 && (v & (1 << (bits - 1))) != 0) v -= 1 << bits;
|
||||
return v;
|
||||
}
|
||||
|
||||
/// SQLite varint (1~9 바이트, 큰 쪽 먼저) → (값, 바이트 수)
|
||||
(int, int) _varint(Uint8List b, int o) {
|
||||
var v = 0;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final c = b[o + i];
|
||||
v = (v << 7) | (c & 0x7F);
|
||||
if (c < 0x80) return (v, i + 1);
|
||||
}
|
||||
return ((v << 8) | b[o + 8], 9);
|
||||
}
|
||||
|
||||
/// CREATE TABLE 의 열 이름 (제약 줄은 빼고). INTEGER PRIMARY KEY 열은 앞에 \u0000
|
||||
List<String> columnsOf(String sql) {
|
||||
final open = sql.indexOf('(');
|
||||
final close = sql.lastIndexOf(')');
|
||||
if (open < 0 || close <= open) return const [];
|
||||
final body = sql.substring(open + 1, close);
|
||||
final parts = <String>[];
|
||||
var depth = 0;
|
||||
var quote = '';
|
||||
var cur = StringBuffer();
|
||||
for (final ch in body.split('')) {
|
||||
if (quote.isNotEmpty) {
|
||||
cur.write(ch);
|
||||
if (ch == quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (ch == '"' || ch == "'" || ch == '`' || ch == '[') {
|
||||
quote = ch == '[' ? ']' : ch;
|
||||
cur.write(ch);
|
||||
} else if (ch == '(') {
|
||||
depth++;
|
||||
cur.write(ch);
|
||||
} else if (ch == ')') {
|
||||
depth--;
|
||||
cur.write(ch);
|
||||
} else if (ch == ',' && depth == 0) {
|
||||
parts.add(cur.toString());
|
||||
cur = StringBuffer();
|
||||
} else {
|
||||
cur.write(ch);
|
||||
}
|
||||
}
|
||||
parts.add(cur.toString());
|
||||
final out = <String>[];
|
||||
for (final raw in parts) {
|
||||
final s = raw.trim();
|
||||
if (s.isEmpty) continue;
|
||||
if (RegExp(r'^(constraint|primary|unique|check|foreign)\b', caseSensitive: false).hasMatch(s)) continue;
|
||||
final m = RegExp(r'^("([^"]|"")*"|`[^`]*`|\[[^\]]*\]|\S+)').firstMatch(s);
|
||||
if (m == null) continue;
|
||||
var name = m[0]!;
|
||||
if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('`') && name.endsWith('`')) || (name.startsWith('[') && name.endsWith(']'))) {
|
||||
name = name.substring(1, name.length - 1).replaceAll('""', '"');
|
||||
}
|
||||
final ipk = RegExp(r'\binteger\s+primary\s+key\b', caseSensitive: false).hasMatch(s) &&
|
||||
!RegExp(r'\bdesc\b', caseSensitive: false).hasMatch(s);
|
||||
out.add(ipk ? '\u0000$name' : name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ import '../l10n/tr.dart';
|
|||
import 'explorer_bookmarks.dart';
|
||||
import 'explorer_search.dart';
|
||||
import 'remote_thumb_view.dart';
|
||||
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
|
||||
import 'quick_look.dart';
|
||||
import 'file_tools.dart' show showFileHash, showFolderProperties;
|
||||
import '../core/file_attributes.dart' show readFileAttributes;
|
||||
|
|
@ -2089,6 +2090,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (viewable) ('view', Icons.visibility_outlined, tr('보기')),
|
||||
// X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용)
|
||||
if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')),
|
||||
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
|
||||
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
|
||||
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
|
||||
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
|
||||
if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||||
|
|
@ -2172,6 +2175,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _grid(pane, e.path);
|
||||
case 'text':
|
||||
await _viewText(e.path);
|
||||
case 'sqlite':
|
||||
await openSqliteViewer(context, c, e.path);
|
||||
case 'openWith':
|
||||
final local = await _fetch(e.path);
|
||||
if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/sqlite_reader.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// SQLite 로 볼 확장자
|
||||
const sqliteExtensions = {'db', 'sqlite', 'sqlite3', 'db3'};
|
||||
|
||||
/// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 보기 (읽기 전용) - 원격 파일은 임시로 받아서 연다
|
||||
Future<void> openSqliteViewer(BuildContext context, AppController c, String path) =>
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => SqliteViewerPage(c: c, path: path)));
|
||||
|
||||
class SqliteViewerPage extends StatefulWidget {
|
||||
final AppController c;
|
||||
final String path;
|
||||
const SqliteViewerPage({super.key, required this.c, required this.path});
|
||||
|
||||
@override
|
||||
State<SqliteViewerPage> createState() => _SqliteViewerPageState();
|
||||
}
|
||||
|
||||
class _SqliteViewerPageState extends State<SqliteViewerPage> {
|
||||
static const _pageRows = 200;
|
||||
SqliteFile? _db;
|
||||
List<SqliteTable> _tables = const [];
|
||||
SqliteTable? _table;
|
||||
List<SqliteRow> _rows = const [];
|
||||
int? _count;
|
||||
bool _more = false, _busy = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_open());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_db?.close());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
try {
|
||||
final local = isDav(widget.path) ? await vLocalCopy(widget.path, await widget.c.services.storage.tempDirectory()) : widget.path;
|
||||
final db = await SqliteFile.open(local);
|
||||
if (!mounted) {
|
||||
await db.close();
|
||||
return;
|
||||
}
|
||||
_db = db;
|
||||
_tables = await db.tables();
|
||||
if (_tables.isNotEmpty) {
|
||||
await _pick(_tables.first);
|
||||
} else if (mounted) {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = e is SqliteFormatException ? tr('SQLite 파일이 아니거나 깨졌습니다') : trf('열 수 없습니다: {0}', [e]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pick(SqliteTable t) async {
|
||||
setState(() {
|
||||
_table = t;
|
||||
_rows = const [];
|
||||
_count = null;
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
final db = _db!;
|
||||
try {
|
||||
if (t.withoutRowid) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = tr('WITHOUT ROWID 테이블은 아직 볼 수 없습니다');
|
||||
});
|
||||
return;
|
||||
}
|
||||
final rows = await db.rows(t, limit: _pageRows);
|
||||
final count = await db.count(t);
|
||||
if (!mounted || _table != t) return;
|
||||
setState(() {
|
||||
_rows = rows;
|
||||
_count = count;
|
||||
_more = rows.length < count;
|
||||
_busy = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = tr('SQLite 파일이 아니거나 깨졌습니다');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
final t = _table!;
|
||||
setState(() => _busy = true);
|
||||
final more = await _db!.rows(t, offset: _rows.length, limit: _pageRows);
|
||||
if (!mounted || _table != t) return;
|
||||
setState(() {
|
||||
_rows = [..._rows, ...more];
|
||||
_more = _rows.length < (_count ?? 0);
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
String _cell(Object? v) => switch (v) {
|
||||
null => 'NULL',
|
||||
Uint8List b => trf('(BLOB {0}바이트)', [b.length]),
|
||||
String s => s.length > 120 ? '${s.substring(0, 120).replaceAll('\n', ' ')} …' : s.replaceAll('\n', ' '),
|
||||
_ => '$v',
|
||||
};
|
||||
|
||||
void _showCell(String column, Object? v) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(column),
|
||||
content: SelectableText(
|
||||
v is Uint8List ? v.take(256).map((x) => x.toRadixString(16).padLeft(2, '0')).join(' ') + (v.length > 256 ? ' …' : '') : '${v ?? 'NULL'}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = _table;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(vBasename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: Text(tr('읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
),
|
||||
if (_tables.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: DropdownButton<SqliteTable>(
|
||||
isExpanded: true,
|
||||
value: t,
|
||||
items: [for (final x in _tables) DropdownMenuItem(value: x, child: Text(x.name, overflow: TextOverflow.ellipsis))],
|
||||
onChanged: _busy ? null : (x) => _pick(x!),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_count != null) Text(trf('{0}행', [_count!]), style: const TextStyle(color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))),
|
||||
if (_busy && _rows.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())),
|
||||
if (!_busy && _error == null && _tables.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('테이블이 없습니다'))),
|
||||
if (t != null && _rows.isNotEmpty)
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowHeight: 36,
|
||||
dataRowMinHeight: 32,
|
||||
dataRowMaxHeight: 40,
|
||||
columns: [for (final col in t.displayColumns) DataColumn(label: Text(col, style: const TextStyle(fontWeight: FontWeight.w600)))],
|
||||
rows: [
|
||||
for (final r in _rows)
|
||||
DataRow(cells: [
|
||||
for (var i = 0; i < t.columns.length; i++)
|
||||
DataCell(
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(_cell(i < r.values.length ? r.values[i] : null),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: (i < r.values.length ? r.values[i] : null) == null ? JjColors.textDim : null)),
|
||||
),
|
||||
onTap: () => _showCell(t.displayColumns[i], i < r.values.length ? r.values[i] : null),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_more)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : _loadMore,
|
||||
child: Text(trf('더 보기 ({0} / {1})', [_rows.length, _count ?? 0])),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,142 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/sqlite_reader.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/sqlite_viewer.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite 읽기 전용 - 시험 파일은 진짜 SQLite (python sqlite3) 가 만든 것
|
||||
/// (tool/fixtures/make_sqlite_fixtures.py → test/fixtures/sqlite)
|
||||
void main() {
|
||||
String fx(String name) => p.join('test', 'fixtures', 'sqlite', name);
|
||||
|
||||
test('basic.db: 테이블 · 열 이름 (INTEGER PRIMARY KEY · 따옴표 · 제약 줄 뺌) · WITHOUT ROWID 표시', () async {
|
||||
final db = await SqliteFile.open(fx('basic.db'));
|
||||
addTearDown(db.close);
|
||||
final ts = await db.tables();
|
||||
expect(ts.map((t) => t.name), ['people', 'odd name', 'kv']);
|
||||
expect(ts[0].displayColumns, ['id', 'name', 'age', 'score', 'photo', 'note']);
|
||||
expect(ts[1].displayColumns, ['a b', 'c', 'd', 'e'], reason: '따옴표 · [ ] · ` 이름, CONSTRAINT 줄은 빼고, DEFAULT (1+2) 의 쉼표에 속지 않음');
|
||||
expect(ts[2].withoutRowid, isTrue);
|
||||
await expectLater(db.rows(ts[2]), throwsA(isA<SqliteFormatException>()));
|
||||
});
|
||||
|
||||
test('basic.db: 값 - 한글 · 일본어 · NULL · 음수 · 64비트 정수 · 실수 · BLOB · 넘친 페이지의 긴 글 · rowid = INTEGER PRIMARY KEY', () async {
|
||||
final db = await SqliteFile.open(fx('basic.db'));
|
||||
addTearDown(db.close);
|
||||
final people = (await db.tables()).first;
|
||||
final rows = await db.rows(people);
|
||||
expect(rows.map((r) => r.rowid), [1, 2, 5, 7]);
|
||||
expect(rows[0].values.take(4), [1, '홍길동', 30, 91.5]);
|
||||
expect(rows[0].values[4], Uint8List.fromList([0, 1, 2]));
|
||||
expect(rows[0].values[5], isNull);
|
||||
expect(rows[1].values.sublist(2, 4), [-5, -0.25]);
|
||||
expect(rows[1].values[5], 'x' * 5000, reason: '넘친 페이지 (페이지 1024 바이트) 를 이어 읽음');
|
||||
expect(rows[2].values.sublist(0, 4), [5, '日本語', 9007199254740993, 1e100]);
|
||||
expect(rows[2].values[4], Uint8List(0));
|
||||
expect(rows[3].values.sublist(1, 3), [null, 0]);
|
||||
expect(rows[3].values[5], '줄\n바꿈');
|
||||
expect(await db.count(people), 4);
|
||||
final odd = (await db.tables())[1];
|
||||
expect((await db.rows(odd)).single.values, ['v', 1, 2, 3]);
|
||||
});
|
||||
|
||||
test('big.db: 2000 행 (안쪽 페이지 여러 단계) - 차례대로 · 건너뛰기 · 개수', () async {
|
||||
final db = await SqliteFile.open(fx('big.db'));
|
||||
addTearDown(db.close);
|
||||
final t = (await db.tables()).single;
|
||||
expect(await db.count(t), 2000);
|
||||
final first = await db.rows(t, limit: 3);
|
||||
expect(first.map((r) => r.values[1]), ['row 0001 y', 'row 0002 yy', 'row 0003 yyy']);
|
||||
final page = await db.rows(t, offset: 1500, limit: 2);
|
||||
expect(page.map((r) => r.rowid), [1501, 1502]);
|
||||
final all = await db.rows(t, limit: 5000);
|
||||
expect(all.length, 2000);
|
||||
expect([for (final r in all) r.rowid], [for (var i = 1; i <= 2000; i++) i], reason: 'rowid 차례');
|
||||
});
|
||||
|
||||
test('utf16.db: UTF-16LE 글', () async {
|
||||
final db = await SqliteFile.open(fx('utf16.db'));
|
||||
addTearDown(db.close);
|
||||
final t = (await db.tables()).single;
|
||||
expect(db.encoding, 2);
|
||||
expect((await db.rows(t)).map((r) => r.values.single), ['가나다', 'abc']);
|
||||
});
|
||||
|
||||
test('SQLite 가 아닌 파일 · 잘린 파일은 SqliteFormatException (파일을 바꾸지 않음)', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_sqlite_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final txt = File(p.join(tmp.path, 'a.db'))..writeAsStringSync('hello');
|
||||
await expectLater(SqliteFile.open(txt.path), throwsA(isA<SqliteFormatException>()));
|
||||
// 머리만 남기고 자른 파일: 열 수는 있어도 페이지를 읽을 때 오류
|
||||
final cut = File(p.join(tmp.path, 'cut.db'))..writeAsBytesSync(File(fx('big.db')).readAsBytesSync().sublist(0, 1024));
|
||||
final db = await SqliteFile.open(cut.path);
|
||||
addTearDown(db.close);
|
||||
final t = (await db.tables()).single;
|
||||
await expectLater(db.rows(t, limit: 5000), throwsA(isA<SqliteFormatException>()));
|
||||
final before = File(fx('basic.db')).readAsBytesSync();
|
||||
final b = await SqliteFile.open(fx('basic.db'));
|
||||
await b.rows((await b.tables()).first);
|
||||
await b.close();
|
||||
expect(File(fx('basic.db')).readAsBytesSync(), before, reason: '읽기만');
|
||||
});
|
||||
Future<void> openPage(WidgetTester tester, String path, bool Function() ready) async {
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
await tester.pumpWidget(MaterialApp(home: SqliteViewerPage(c: c, path: path)));
|
||||
for (var i = 0; i < 500 && !ready(); i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('보기 화면: 첫 테이블 · 행 수 · 값 (NULL · BLOB) · 칸을 누르면 전체 값 · 다른 테이블 고르기', (tester) async {
|
||||
await openPage(tester, fx('basic.db'), () => find.text('홍길동').evaluate().isNotEmpty);
|
||||
expect(find.text('홍길동'), findsOneWidget);
|
||||
expect(find.text('4행'), findsOneWidget);
|
||||
expect(find.text('NULL'), findsWidgets);
|
||||
expect(find.text('(BLOB 3바이트)'), findsOneWidget);
|
||||
expect(find.textContaining('읽기 전용'), findsOneWidget);
|
||||
await tester.tap(find.text('홍길동'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(AlertDialog, 'name'), findsOneWidget);
|
||||
await tester.tap(find.text('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
// WITHOUT ROWID 테이블은 안내
|
||||
await tester.tap(find.byType(DropdownButton<SqliteTable>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('kv').last);
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('WITHOUT ROWID 테이블은 아직 볼 수 없습니다'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
testWidgets('보기 화면: 2000 행은 200 씩 [더 보기] · SQLite 가 아니면 알림', (tester) async {
|
||||
await openPage(tester, fx('big.db'), () => find.textContaining('더 보기').evaluate().isNotEmpty);
|
||||
expect(find.text('더 보기 (200 / 2000)'), findsOneWidget);
|
||||
await tester.tap(find.text('더 보기 (200 / 2000)'));
|
||||
for (var i = 0; i < 50 && find.text('더 보기 (400 / 2000)').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('더 보기 (400 / 2000)'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_sqlv_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final txt = File(p.join(tmp.path, 'x.db'))..writeAsStringSync('not sqlite');
|
||||
await openPage(tester, txt.path, () => find.text('SQLite 파일이 아니거나 깨졌습니다').evaluate().isNotEmpty);
|
||||
expect(find.text('SQLite 파일이 아니거나 깨졌습니다'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""SQLite 읽기 시험 (test/sqlite_reader_test.dart) 의 시험 파일을 만든다: python tool/fixtures/make_sqlite_fixtures.py
|
||||
X-plore 비교 5-1 ④ (관리자 지시 10/21). 앱은 SQLite 라이브러리 없이 파일 형식을 직접 읽으므로, 진짜 SQLite (파이썬 sqlite3) 가 만든
|
||||
파일로 시험한다. 만든 파일은 test/fixtures/sqlite 에 두고 git 에 넣는다 (시험 때 파이썬이 없어도 되게).
|
||||
- basic.db: 페이지 1024 바이트 · INTEGER PRIMARY KEY · 한글 · NULL · 음수 · 64비트 정수 · 실수 · BLOB · 넘친 페이지 (긴 글) ·
|
||||
이상한 열 이름 · 제약 줄 · WITHOUT ROWID 테이블
|
||||
- big.db: 2000 행 (안쪽 페이지가 여러 단계)
|
||||
- utf16.db: UTF-16LE 인코딩
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
OUT = os.path.join(os.path.dirname(__file__), '..', '..', 'test', 'fixtures', 'sqlite')
|
||||
|
||||
|
||||
def fresh(name, encoding=None):
|
||||
path = os.path.join(OUT, name)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
con = sqlite3.connect(path)
|
||||
con.execute('PRAGMA page_size = 1024')
|
||||
if encoding:
|
||||
con.execute("PRAGMA encoding = '%s'" % encoding)
|
||||
con.execute('PRAGMA journal_mode = DELETE')
|
||||
return con
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
con = fresh('basic.db')
|
||||
con.execute('CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, score REAL, photo BLOB, note TEXT)')
|
||||
con.executemany('INSERT INTO people VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
(1, '홍길동', 30, 91.5, b'\x00\x01\x02', None),
|
||||
(2, 'Alice', -5, -0.25, None, 'x' * 5000),
|
||||
(5, '日本語', 9007199254740993, 1e100, b'', 'short'),
|
||||
(7, None, 0, 0.0, None, '줄\n바꿈'),
|
||||
])
|
||||
con.execute('CREATE TABLE "odd name" ("a b" TEXT, [c] INT, `d` INT, e INT DEFAULT (1+2), CONSTRAINT u UNIQUE (c))')
|
||||
con.execute('INSERT INTO "odd name" VALUES (?, ?, ?, ?)', ('v', 1, 2, 3))
|
||||
con.execute('CREATE TABLE kv (k TEXT PRIMARY KEY, v) WITHOUT ROWID')
|
||||
con.execute("INSERT INTO kv VALUES ('a', 1)")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
con = fresh('big.db')
|
||||
con.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)')
|
||||
con.executemany('INSERT INTO t VALUES (?, ?)', [(i, 'row %04d %s' % (i, 'y' * (i % 50))) for i in range(1, 2001)])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
con = fresh('utf16.db', 'UTF-16le')
|
||||
con.execute('CREATE TABLE w (word TEXT)')
|
||||
con.executemany('INSERT INTO w VALUES (?)', [('가나다',), ('abc',)])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue