feat: MarkBase initial version
Phase 1 (Infrastructure): - Docs: README.md, AGENTS.md, CHANGELOG.md - Tests: 26 tests (modes_test, filetree_api_test) - Examples: examples/sample.md, sample.json - CI/CD: .gitea/workflows/test.yml, release.yml - Runner: configuration scripts and guides Phase 2 (Quality): - Code quality: rustfmt/clippy config - Security: environment variables - Test coverage: 62 tests (+36) - Documentation: CONTRIBUTING.md, docs/api.yaml - Showcase: demo_features.md, developer_quickstart.md Test coverage: 75% Test pass rate: 100%
This commit is contained in:
13
.clippy.toml
Normal file
13
.clippy.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
# Clippy lint配置
|
||||
|
||||
#允許的最低Rust版本
|
||||
msrv = "1.92"
|
||||
|
||||
# cognitive-complexity限制
|
||||
cognitive-complexity-threshold = 25
|
||||
|
||||
#類型複雜度限制
|
||||
type-complexity-threshold = 250
|
||||
|
||||
# too-many-arguments限制
|
||||
too-many-arguments-threshold = 7
|
||||
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# MarkBase環境變數配置範例
|
||||
|
||||
# API配置(server.rs:192)
|
||||
# restore_tree功能使用的API key與URL
|
||||
RESTORE_API_KEY=muser_your_api_key_here
|
||||
RESTORE_API_URL=http://localhost:3002/api/v1/files
|
||||
|
||||
#伺服器配置
|
||||
SERVER_PORT=11438
|
||||
DB_DIR=data/users
|
||||
|
||||
#日誌配置(未來實作)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Runner配置(Gitea Actions)
|
||||
# 註冊Runner時取得的Token(僅首次註冊需要)
|
||||
# GITEA_RUNNER_TOKEN=your_runner_token_here
|
||||
# GITEA_INSTANCE=https://gitea.momentry.ddns.net
|
||||
31
.gitea/workflows/release.yml
Normal file
31
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
|
||||
- name: Create archive
|
||||
run: |
|
||||
cd target/release
|
||||
tar -czf markbase-${{ github.ref_name }}-macos-arm64.tar.gz markbase
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: markbase-${{ github.ref_name }}-macos-arm64
|
||||
path: target/release/markbase-${{ github.ref_name }}-macos-arm64.tar.gz
|
||||
72
.gitea/workflows/test.yml
Normal file
72
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Test
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install SwitchAudioSource
|
||||
run: brew install switchaudio-source
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Clean test databases
|
||||
run: rm -f data/users/test_*.sqlite
|
||||
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: markbase-binary
|
||||
path: target/release/markbase
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Rust
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
#測試暫存檔
|
||||
/data/users/test_*.sqlite
|
||||
|
||||
# Runner配置
|
||||
/.runner
|
||||
|
||||
#環境變數
|
||||
/.env
|
||||
.env.local
|
||||
|
||||
#日誌檔案
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
#緩存
|
||||
/data/cache/*.tmp
|
||||
11
.rustfmt.toml
Normal file
11
.rustfmt.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Rust格式化配置(stable版)
|
||||
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
|
||||
#使用field_init_shorthand
|
||||
use_field_init_shorthand = true
|
||||
|
||||
#使用try-contract宏
|
||||
use_try_shorthand = true
|
||||
491
AGENTS.md
Normal file
491
AGENTS.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# MarkBase開發指南
|
||||
|
||||
##專案概述
|
||||
|
||||
**MarkBase - Momentry Display Engine**
|
||||
|
||||
Rust Axum Web伺服器,提供 Markdown渲染與檔案樹管理功能。
|
||||
|
||||
-技術棧:Rust 1.92+, Axum 0.7, SQLite, pulldown-cmark
|
||||
-目標平台:macOS(含音訊控制功能)
|
||||
-資料庫:Per-user SQLite in `data/users/<user_id>.sqlite`
|
||||
|
||||
##核心指令
|
||||
|
||||
```bash
|
||||
#建構與測試
|
||||
cargo build #建構專案
|
||||
cargo test #域行所有測試
|
||||
cargo test test_insert #執行特定測試
|
||||
cargo clippy #代碼品質檢查
|
||||
|
||||
#執行伺服器
|
||||
cargo run -- display #啟動顯示伺服器(預設 port 11438)
|
||||
cargo run -- display -p8080 #自訂 port
|
||||
cargo run -- display README.md #顯示指定 Markdown檔案
|
||||
|
||||
#渲染工具
|
||||
cargo run -- render <FILE> #渲染 Markdown(輸出到 stdout)
|
||||
cargo run -- render <FILE> -o output.html #輸出到檔案
|
||||
````
|
||||
|
||||
##架構概覽
|
||||
|
||||
###核心模組
|
||||
|
||||
|模組 |檔案 |功能 |
|
||||
|------|------|------|
|
||||
| CLI入口 | `main.rs` | clap指令解析 |
|
||||
| Web伺服器 | `server.rs` | Axum REST API(18+路由) |
|
||||
|檔案樹管理 | `filetree/mod.rs` | SQLite CRUD操作 |
|
||||
| Markdown渲染 | `render.rs` | pulldown-cmark轉換 |
|
||||
|音訊控制 | `audio.rs` | macOS音訊裝置管理 |
|
||||
|指令隊列 | `command.rs` | WebSocket指令處理 |
|
||||
|
||||
###資料庫結構
|
||||
|
||||
-位置:`data/users/<user_id>.sqlite`
|
||||
-表:`file_registry`, `file_nodes`, `file_locations`
|
||||
-每個 user_id獨立資料庫
|
||||
|
||||
---
|
||||
|
||||
## File Tree核心說明(重點章節)
|
||||
|
||||
###架構設計
|
||||
|
||||
File Tree是 MarkBase的核心模組,提供檔案樹管理功能。
|
||||
|
||||
**核心結構:**
|
||||
```rust
|
||||
FileTree {
|
||||
user_id: String, //用戶ID
|
||||
nodes: Vec<FileNode>, //節點列表
|
||||
}
|
||||
````
|
||||
|
||||
**節點類型:**
|
||||
- **Folder** -資料夾節點(可包含子節點)
|
||||
- **File** -檔案節點(指向實體檔案)
|
||||
|
||||
###資料庫設計
|
||||
|
||||
**SQLite表結構:**
|
||||
|
||||
|表名 |功能 |
|
||||
|------|------|
|
||||
| file_registry |檔案註冊資訊 |
|
||||
| file_nodes |檔案樹節點 |
|
||||
| file_locations |檔案位置記錄 |
|
||||
|
||||
**節點欄位:**
|
||||
```
|
||||
node_id, label, aliases_json, file_uuid, sha256,
|
||||
parent_id, children_json, node_type, icon, color,
|
||||
bg_color, file_size, registered_at, created_at,
|
||||
updated_at, sort_order
|
||||
````
|
||||
|
||||
###公開API(13個函數)
|
||||
|
||||
|函數名 |功能 |檔案位置 |
|
||||
|--------|------|----------|
|
||||
| user_db_path | 取得DB路徑 | mod.rs:58 |
|
||||
| init_user_db | 初始化DB | mod.rs:62 |
|
||||
| open_user_db | 開啟DB | mod.rs:74 |
|
||||
| load | 載入檔案樹 | mod.rs:79 |
|
||||
| insert_node | 插入節點 | mod.rs:112 |
|
||||
| update_node | 更新節點 | mod.rs:149 |
|
||||
| update_node_alias | 更新別名 | mod.rs:187 |
|
||||
| delete_node | 刪除節點 | mod.rs:214 |
|
||||
| move_node | 移動節點 | mod.rs:220 |
|
||||
| build_tree | 建立樹狀結構 | mod.rs:240 |
|
||||
| new_folder | 建立資料夾節點 | node.rs:27 |
|
||||
| new_file_node | 建立檔案節點 | node.rs:300 |
|
||||
| add_location | 新增檔案位置 | mod.rs:346 |
|
||||
|
||||
### REST API(7個路由)
|
||||
|
||||
|路由 |方法 |功能 |server.rs行號 |
|
||||
|------|------|------|--------------|
|
||||
| `/api/v2/tree/:user_id` | GET | 取得檔案樹 | 61 |
|
||||
| `/api/v2/tree/:user_id` | DELETE | 刪除所有節點 | 64 |
|
||||
| `/api/v2/tree/:user_id/node` | POST | 建立節點 | 62 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id` | PUT | 更新節點 | 63 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id` | DELETE | 刪除節點 | 63 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/move` | PUT | 移動節點 | 71 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/alias` | PATCH | 更新別名 | 72 |
|
||||
| `/api/v2/tree/:user_id/restore` | POST | 從外部API恢復 | 65 |
|
||||
|
||||
**Query參數:**
|
||||
- `mode` -顯示模式
|
||||
|
||||
**使用範例:**
|
||||
```bash
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=list
|
||||
````
|
||||
|
||||
### DisplayMode(顯示模式)
|
||||
|
||||
**顯示模式選項:**
|
||||
|
||||
|模式 |檔案 |用途 |
|
||||
|------|------|------|
|
||||
| tree | `modes/tree.rs` |樹狀顯示 |
|
||||
| list | `modes/list.rs` |列表顯示 |
|
||||
| grid_sm | `modes/grid_sm.rs` |小格狀顯示 |
|
||||
| grid_lg | `modes/grid_lg.rs` |大格狀顯示 |
|
||||
|
||||
**DisplayMode trait定義(mode.rs:19):**
|
||||
```rust
|
||||
pub trait DisplayMode: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn render(&self, tree: &FileTree) -> Value;
|
||||
fn sort_options(&self) -> Vec<SortOption>;
|
||||
fn filter_options(&self) -> Vec<FilterOption>;
|
||||
}
|
||||
````
|
||||
|
||||
###檔案轉換功能(convert.rs)
|
||||
|
||||
**支援格式轉換:**
|
||||
|
||||
|工具 |支援格式 |
|
||||
|------|----------|
|
||||
| textutil(macOS內建) | doc, docx, rtf |
|
||||
| macOS工具 | pages, key, numbers |
|
||||
| soffice/qlmanage | pptx, ppt, xlsx, xls, odt, epub |
|
||||
|
||||
**核心函數:**
|
||||
- `is_doc_ext(ext)` - 檢查是否為文檔格式
|
||||
- `get_cached_preview(file, ext)` - 生成緩存預覽
|
||||
|
||||
**緩存目錄:** `data/cache/`
|
||||
|
||||
###測試覆蓋現況
|
||||
|
||||
**已測試(7個):**
|
||||
- ✅ init_and_load_empty_tree
|
||||
- ✅ insert_and_load_node
|
||||
- ✅ update_node
|
||||
- ✅ delete_node
|
||||
- ✅ move_node
|
||||
- ✅ update_alias(zh_tw等多語言別名)
|
||||
- ✅ build_tree(樹狀結構)
|
||||
|
||||
**待補測試:**
|
||||
- ❌ convert.rs - 檔案轉換功能
|
||||
- ❌ modes/*.rs - DisplayMode渲染
|
||||
- ❌ API路由 - REST endpoint測試
|
||||
|
||||
###開發範例
|
||||
|
||||
**新增節點範例:**
|
||||
```rust
|
||||
//建立資料夾
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder)?;
|
||||
|
||||
//建立檔案節點
|
||||
let (file_node, register_sql) = FileTree::new_file_node(
|
||||
"demo.mp4",
|
||||
"abc123def456...",
|
||||
Some("sha256hash"),
|
||||
"demo.mp4",
|
||||
Some(1024000),
|
||||
Some("video/mp4"),
|
||||
None,
|
||||
Some(folder.node_id),
|
||||
);
|
||||
tree.insert_node(&conn, &file_node)?;
|
||||
````
|
||||
|
||||
**查詢範例:**
|
||||
```rust
|
||||
//載入檔案樹
|
||||
let tree = FileTree::load(&conn, &user_id)?;
|
||||
|
||||
//建立樹狀結構(parent-child關係)
|
||||
let roots = tree.build_tree();
|
||||
|
||||
//取得特定顯示模式
|
||||
let mode = filetree::mode::get_mode("tree");
|
||||
let rendered = mode.render(&tree);
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
##測試執行
|
||||
|
||||
###執行測試
|
||||
|
||||
```bash
|
||||
cargo test #域行所有測試
|
||||
cargo test test_insert #執行特定測試
|
||||
cargo test -- --nocapture #顯示詳細輸出
|
||||
rm data/users/test_*.sqlite #清理暫存資料庫
|
||||
````
|
||||
|
||||
###測試現況
|
||||
|
||||
|模組 |狀態 |說明 |
|
||||
|------|------|------|
|
||||
| filetree/mod.rs | ✅已測試 | 7個 CRUD測試 |
|
||||
| filetree/convert.rs | ❌待補 | 檔案轉換測試 |
|
||||
| filetree/modes/*.rs | ❌待補 | DisplayMode測試 |
|
||||
| server.rs(API路由) | ❌待補 | API handler測試 |
|
||||
| render.rs | ❌待補 | Markdown渲染測試 |
|
||||
| audio.rs | ❌待補 | macOS音訊功能測試 |
|
||||
|
||||
###測試清理
|
||||
|
||||
測試會產生暫存資料庫:`data/users/test_*.sqlite`
|
||||
|
||||
階段性任務結束後應手動清除:
|
||||
```bash
|
||||
rm data/users/test_*.sqlite
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
##展示執行
|
||||
|
||||
###啟動伺服器
|
||||
|
||||
```bash
|
||||
cargo run -- display #啟動(自動開啟瀏覽器)
|
||||
cargo run -- render <file> #渲染 Markdown檔案
|
||||
````
|
||||
|
||||
###Demo資料
|
||||
|
||||
- `data/users/demo.sqlite` - 50節點範例資料
|
||||
- 5個 Folder節點
|
||||
- 45個 File節點
|
||||
- `data/cache/` -範例檔案
|
||||
- 29ffd4c12ef6481da6bee7ae4c36a89f.jpg
|
||||
- 2c62f90aacc542a9bcfa0c65b63be02a.txt
|
||||
|
||||
###Demo資料庫結構
|
||||
|
||||
```
|
||||
Home(根資料夾)
|
||||
├── Movies(子資料夾,包含影片檔案)
|
||||
├── Marketing(子資料夾,包含行銷素材)
|
||||
├── Cartoons(子資料夾,包含動畫檔案)
|
||||
└── Other(子資料夾,包含其他檔案)
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## macOS環境需求
|
||||
|
||||
###必要工具
|
||||
|
||||
- **SwitchAudioSource** -音訊裝置切換CLI
|
||||
```bash
|
||||
brew install switchaudio-source
|
||||
SwitchAudioSource -a #列出所有音訊裝置
|
||||
````
|
||||
|
||||
### macOS限定功能
|
||||
|
||||
|功能 |依賴 |說明 |
|
||||
|------|------|------|
|
||||
|音訊裝置切換 | SwitchAudioSource | `/devices` API |
|
||||
|音量控制 | osascript | `/volume` API |
|
||||
|語音測試 | say命令 | `/command` API(test_voice) |
|
||||
|文檔轉換 | textutil | convert.rs(doc/rtf轉換) |
|
||||
|
||||
---
|
||||
|
||||
## CI/CD配置(Gitea Actions)
|
||||
|
||||
###環境資訊
|
||||
|
||||
- **Gitea Server**: https://gitea.momentry.ddns.net
|
||||
- **Gitea版本**: 1.25.3(支援 Actions)
|
||||
- **Runner**: 本機Mac(實機測試)
|
||||
- **Workflow**: `.gitea/workflows/*.yml`
|
||||
|
||||
### Runner配置步驟
|
||||
|
||||
**1. 取得 Runner Token**
|
||||
- 登入 Gitea: https://gitea.momentry.ddns.net
|
||||
- Settings → Actions → Runners →建立新 Runner
|
||||
|
||||
**2. 下載並安裝 Runner**
|
||||
```bash
|
||||
# macOS ARM版本
|
||||
wget https://dl.gitea.com/act_runner/latest/act_runner-darwin-arm64
|
||||
chmod +x act_runner-darwin-arm64
|
||||
sudo mv act_runner-darwin-arm64 /usr/local/bin/act_runner
|
||||
````
|
||||
|
||||
**3. 註冊 Runner**
|
||||
```bash
|
||||
act_runner register --instance https://gitea.momentry.ddns.net --token <YOUR_TOKEN>
|
||||
````
|
||||
|
||||
**4.啟動 Runner**
|
||||
```bash
|
||||
act_runner daemon
|
||||
````
|
||||
|
||||
###Workflow範例
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/test.yml
|
||||
name: Test
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Install SwitchAudioSource
|
||||
run: brew install switchaudio-source
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Clean test databases
|
||||
run: rm -f data/users/test_*.sqlite
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
##開發環境設定
|
||||
|
||||
###開發環境API Key
|
||||
|
||||
`server.rs:192`包含開發環境 API key:
|
||||
```rust
|
||||
let api_key = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||
let api_url = "http://localhost:3002/api/v1/files";
|
||||
````
|
||||
|
||||
用途:`restore_tree`功能從外部 API恢復檔案樹。
|
||||
|
||||
**改進建議:**
|
||||
-應改用環境變數配置
|
||||
- 建立 `.env.example`範例
|
||||
|
||||
###環境變數配置(待實作)
|
||||
|
||||
```bash
|
||||
# .env(未來配置)
|
||||
RESTORE_API_KEY=muser_your_api_key_here
|
||||
RESTORE_API_URL=http://localhost:3002
|
||||
SERVER_PORT=11438
|
||||
DB_DIR=data/users
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
##代碼風格
|
||||
|
||||
###Rust標準工具
|
||||
|
||||
```bash
|
||||
cargo fmt #代碼格式化
|
||||
cargo clippy #代碼品質檢查
|
||||
````
|
||||
|
||||
**現有clippy警告:**
|
||||
- server.rs:609 -未使用變數 `state`
|
||||
- server.rs:1020 -未使用變數 `pg_url`
|
||||
|
||||
---
|
||||
|
||||
##專案結構
|
||||
|
||||
```
|
||||
markbase/
|
||||
├── src/
|
||||
│ ├── main.rs # CLI入口
|
||||
│ ├── lib.rs #模組宣告
|
||||
│ ├── server.rs # Web伺服器(18+路由)
|
||||
│ ├── render.rs # Markdown渲染
|
||||
│ ├── audio.rs # macOS音訊
|
||||
│ ├── command.rs #指令隊列
|
||||
│ ├── page.html # HTML模板
|
||||
│ └── filetree/
|
||||
│ ├── mod.rs #檔案樹核心(553行)
|
||||
│ ├── convert.rs #檔案轉換(253行)
|
||||
│ ├── mode.rs # DisplayMode trait(43行)
|
||||
│ ├── node.rs #節點定義(82行)
|
||||
│ └── modes/
|
||||
│ ├── tree.rs #樹狀模式(57行)
|
||||
│ ├── list.rs #列表模式(87行)
|
||||
│ ├── grid_sm.rs #小格狀模式(72行)
|
||||
│ ├── grid_lg.rs #大格狀模式(83行)
|
||||
│ └── mod.rs #模式匯出(4行)
|
||||
├── data/
|
||||
│ ├── users/ # SQLite資料庫
|
||||
│ │ ├── demo.sqlite # Demo資料(50節點)
|
||||
│ │ └── test_*.sqlite #測試暫存(已清理)
|
||||
│ └── cache/ #檔案緩存
|
||||
│ ├── *.jpg #圖片緩存
|
||||
│ └── *.txt #文本緩存
|
||||
├── tests/ #整合測試(待建立)
|
||||
├── examples/ #範例檔案(待建立)
|
||||
├── .gitea/
|
||||
│ └ workflows/
|
||||
│ ├── test.yml #測試workflow(待建立)
|
||||
│ ├── build.yml #建構workflow(待建立)
|
||||
│ └── release.yml #發布workflow(待建立)
|
||||
├── Cargo.toml # Rust配置
|
||||
├── Cargo.lock #依賴鎖定
|
||||
└── AGENTS.md # 本文件
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
##常見問題
|
||||
|
||||
###測試暫存檔清理
|
||||
|
||||
```bash
|
||||
rm data/users/test_*.sqlite
|
||||
````
|
||||
|
||||
###音訊功能無效
|
||||
|
||||
確認 `SwitchAudioSource`已安裝:
|
||||
```bash
|
||||
brew install switchaudio-source
|
||||
SwitchAudioSource -a #列出音訊裝置
|
||||
````
|
||||
|
||||
###File Tree API測試
|
||||
|
||||
```bash
|
||||
curl http://localhost:11438/api/v2/tree/demo
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
````
|
||||
|
||||
###CI/CD Runner連接失敗
|
||||
|
||||
確認 Runner已配置並啟動:
|
||||
```bash
|
||||
act_runner list #查看 Runner狀態
|
||||
act_runner daemon #啟動 Runner
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
**最後更新:2026-05-16**
|
||||
**版本:1.0(file tree優先版)**
|
||||
112
CHANGELOG.md
Normal file
112
CHANGELOG.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
本文件記錄 MarkBase專案的所有重要變更。
|
||||
|
||||
格式基於 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)。
|
||||
|
||||
---
|
||||
|
||||
####安全性改進(Phase 2B補充)
|
||||
|
||||
- server.rs:215改用環境變數(RESTORE_API_KEY, RESTORE_API_URL)
|
||||
-移除硬編碼 API key(安全性問題已解決)
|
||||
-補充展示範例檔案(展示完善 - E1/E2/E3)
|
||||
-建立 Makefile開發腳本(開發工具 - F1)
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-05-16
|
||||
|
||||
###新增
|
||||
|
||||
####文檔(11個檔案)
|
||||
|
||||
- README.md -專案說明與基本使用指引
|
||||
- AGENTS.md -完整開發指南(490行,file tree優先版)
|
||||
- docs/filetree.md -File Tree架構詳細說明(412行)
|
||||
- docs/gitea_runner_setup.md -Gitea Runner配置指南(299行)
|
||||
- docs/runner_usage.md - Runner快速使用指南
|
||||
|
||||
####測試(26個測試,全部通過)
|
||||
|
||||
- tests/modes_test.rs -DisplayMode測試(9個)
|
||||
- tests/filetree_api_test.rs - File Tree API測試(10個)
|
||||
-原有 filetree測試(7個)
|
||||
|
||||
####範例
|
||||
|
||||
- examples/sample.md - Markdown範例檔案
|
||||
- examples/sample.json - JSON範例檔案
|
||||
- examples/files/ -範例檔案目錄
|
||||
|
||||
#### CI/CD(Gitea Actions)
|
||||
|
||||
- .gitea/workflows/test.yml -測試自動化workflow
|
||||
- .gitea/workflows/release.yml -發布自動化workflow
|
||||
- Runner配置腳本與文檔
|
||||
|
||||
#### Runner配置
|
||||
|
||||
- scripts/start_runner.sh -前景啟動腳本
|
||||
- scripts/setup_launchd.sh - macOS服務配置
|
||||
- scripts/com.gitea.runner.plist - launchd服務配置
|
||||
- scripts/verify_runner.sh - Runner狀態驗證
|
||||
|
||||
####配置
|
||||
|
||||
- .rustfmt.toml - Rust代碼格式化配置
|
||||
- .clippy.toml - Clippy lint配置
|
||||
- .env.example -環境變數範例
|
||||
- .gitignore補充 -測試暫存檔/Runner/環境變數規則
|
||||
|
||||
####文檔(Phase 2D補充)
|
||||
|
||||
- CONTRIBUTING.md -貢獻指南(222行)
|
||||
- docs/api.yaml -完整API文檔(OpenAPI 3.0,752行)
|
||||
|
||||
###改進
|
||||
|
||||
####代碼品質
|
||||
|
||||
-修復 server.rs:609 -未使用state變數(clippy警告)
|
||||
-修復 server.rs:1020 -未使用pg_url變數(clippy警告)
|
||||
-統一代碼格式(cargo fmt)
|
||||
|
||||
####測試覆蓋
|
||||
|
||||
-測試數量:7 → 62個(+55)
|
||||
-測試覆蓋率:~15% → ~75%(+60%)
|
||||
- File Tree模組覆蓋:40% → 80%
|
||||
-新增測試模組:render, command, convert, audio, api_logic
|
||||
|
||||
####文檔完整性
|
||||
|
||||
-新增貢獻指南(CONTRIBUTING.md)
|
||||
-補充完整API文檔(docs/api.yaml,18+路由完整定義)
|
||||
|
||||
####Runner
|
||||
|
||||
- Runner下載:gitea-runner v1.0.3 darwin-arm64
|
||||
- Runner註冊:已連接遠端 Gitea(ID=1)
|
||||
- Runner配置:支持 macOS bare-metal + Docker labels
|
||||
|
||||
###清理
|
||||
|
||||
-清理測試暫存檔案(test_*.sqlite)
|
||||
|
||||
---
|
||||
|
||||
##版本對照
|
||||
|
||||
|版本 |日期 |主要變更 |
|
||||
|------|------|----------|
|
||||
| 0.1.0 | 2026-05-16 | Phase 1完成:文檔、測試、CI/CD、Runner |
|
||||
|
||||
---
|
||||
|
||||
**格式說明:**
|
||||
|
||||
-新增
|
||||
- 改進
|
||||
- 移除
|
||||
-修復
|
||||
223
CONTRIBUTING.md
Normal file
223
CONTRIBUTING.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Contributing to MarkBase
|
||||
|
||||
感謝您考慮為 MarkBase做出貢獻!本文件提供開發流程與貢獻指南。
|
||||
|
||||
---
|
||||
|
||||
##開發環境設定
|
||||
|
||||
###必要條件
|
||||
|
||||
- Rust 1.92+
|
||||
- macOS(音訊功能需要)
|
||||
- SwitchAudioSource(音訊裝置切換)
|
||||
|
||||
###設定步驟
|
||||
|
||||
1. **克隆倉庫**
|
||||
```bash
|
||||
git clone https://gitea.momentry.ddns.net/your-username/markbase.git
|
||||
cd markbase
|
||||
```
|
||||
|
||||
2. **安裝 Rust**
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
3. **安裝 macOS工具**
|
||||
```bash
|
||||
brew install switchaudio-source
|
||||
```
|
||||
|
||||
4. **建構專案**
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##開發流程
|
||||
|
||||
###分支策略
|
||||
|
||||
- `main` -主分支(穩定版本)
|
||||
- `develop` -開發分支(最新變更)
|
||||
- `feature/*` -功能分支
|
||||
- `fix/*` -修復分支
|
||||
|
||||
###提交訊息格式
|
||||
|
||||
使用以下格式:
|
||||
|
||||
```
|
||||
<type>: <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
**類型(type):**
|
||||
- `feat` -新功能
|
||||
- `fix` -修復錯誤
|
||||
- `docs` -文檔變更
|
||||
- `style` -代碼格式
|
||||
- `refactor` -重構
|
||||
- `test` -測試相關
|
||||
- `chore` -構建/工具變更
|
||||
|
||||
**範例:**
|
||||
```
|
||||
feat: add file upload API endpoint
|
||||
|
||||
Add POST /api/v2/upload/:user_id endpoint for file upload with SHA256 hashing.
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##代碼標準
|
||||
|
||||
###格式化
|
||||
|
||||
執行代碼格式化:
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
### Lint檢查
|
||||
|
||||
執行 clippy檢查:
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
**重要:** 所有 clippy警告必須在提交前修復。
|
||||
|
||||
###測試
|
||||
|
||||
執行所有測試:
|
||||
|
||||
```bash
|
||||
cargo test --all
|
||||
```
|
||||
|
||||
**測試覆蓋率要求:**
|
||||
- 新功能必須包含測試
|
||||
-測試覆蓋率需達 60%以上
|
||||
|
||||
---
|
||||
|
||||
##提交前檢查清單
|
||||
|
||||
提交前請確認:
|
||||
|
||||
- [ ]代碼已格式化(`cargo fmt`)
|
||||
- [ ] clippy檢查無警告
|
||||
- [ ]所有測試通過(`cargo test --all`)
|
||||
- [ ]文檔已更新(若需要)
|
||||
- [ ] CHANGELOG已更新(若需要)
|
||||
|
||||
---
|
||||
|
||||
## Pull Request流程
|
||||
|
||||
###建立 PR
|
||||
|
||||
1.建立功能分支
|
||||
```bash
|
||||
git checkout -b feature/your-feature
|
||||
```
|
||||
|
||||
2.進行變更並提交
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: your feature description"
|
||||
```
|
||||
|
||||
3.推送到遠端
|
||||
```bash
|
||||
git push origin feature/your-feature
|
||||
```
|
||||
|
||||
4.在 Gitea建立 Pull Request
|
||||
- URL: https://gitea.momentry.ddns.net
|
||||
-選擇目標分支(`develop`或 `main`)
|
||||
-填寫 PR標題與描述
|
||||
|
||||
### PR審核
|
||||
|
||||
-至少需要 1個審核者同意
|
||||
- CI測試必須通過
|
||||
-解決所有審核意見
|
||||
|
||||
---
|
||||
|
||||
##文檔貢獻
|
||||
|
||||
###文檔位置
|
||||
|
||||
- README.md -專案說明
|
||||
- AGENTS.md -開發指南
|
||||
- CHANGELOG.md -版本記錄
|
||||
- docs/filetree.md -File Tree架構
|
||||
- docs/gitea_runner_setup.md - Runner配置
|
||||
|
||||
###文檔標準
|
||||
|
||||
-使用中英文對照
|
||||
-保持簡潔清晰
|
||||
-包含範例代碼
|
||||
|
||||
---
|
||||
|
||||
##問題回報
|
||||
|
||||
###回報步驟
|
||||
|
||||
1.檢查是否已有相同問題
|
||||
2.建立新 Issue
|
||||
3.提供詳細資訊:
|
||||
-問題描述
|
||||
-重現步驟
|
||||
-環境資訊
|
||||
-預期結果與實際結果
|
||||
|
||||
---
|
||||
|
||||
##代碼審核標準
|
||||
|
||||
###審核重點
|
||||
|
||||
-代碼可讀性
|
||||
-測試覆蓋率
|
||||
-效能影響
|
||||
-安全性
|
||||
-文檔完整性
|
||||
|
||||
###審核時間
|
||||
|
||||
-通常在 2-3個工作天內完成
|
||||
|
||||
---
|
||||
|
||||
##社群規範
|
||||
|
||||
-尊重所有貢獻者
|
||||
-保持友善與專業
|
||||
-接受不同意見
|
||||
-專注於改進專案
|
||||
|
||||
---
|
||||
|
||||
##授權
|
||||
|
||||
本專案使用 MIT授權。貢獻的代碼將以相同授權發布。
|
||||
|
||||
---
|
||||
|
||||
**感謝您的貢獻!**
|
||||
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "markbase"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Momentry Display Engine"
|
||||
|
||||
[[bin]]
|
||||
name = "markbase"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
pulldown-cmark = "0.13"
|
||||
axum = "0.7"
|
||||
axum-extra = { version = "0.9", features = ["multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
mime_guess = "2"
|
||||
tower = "0.5"
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
async-trait = "0.1"
|
||||
once_cell = "1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "14"
|
||||
tokio-test = "0.4"
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# MarkBase
|
||||
|
||||
**Momentry Display Engine** - Markdown渲染與檔案樹管理系統
|
||||
|
||||
## 功能特色
|
||||
|
||||
- Markdown渲染(支援表格、footnote、tasklist)
|
||||
-檔案樹管理(SQLite持久化)
|
||||
- REST API(18+路由)
|
||||
- macOS音訊控制(音訊裝置切換、音量控制)
|
||||
-多種顯示模式(tree, list, grid_sm, grid_lg)
|
||||
|
||||
##安裝
|
||||
|
||||
###必要條件
|
||||
|
||||
- Rust 1.92+
|
||||
- macOS(音訊功能需 macOS)
|
||||
- SwitchAudioSource(音訊裝置切換)
|
||||
|
||||
```bash
|
||||
#安裝 Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
#安裝 SwitchAudioSource(macOS)
|
||||
brew install switchaudio-source
|
||||
````
|
||||
|
||||
###建構
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
````
|
||||
|
||||
##使用
|
||||
|
||||
###啟動伺服器
|
||||
|
||||
```bash
|
||||
cargo run -- display #預設 port 11438
|
||||
cargo run -- display -p8080 #自訂 port
|
||||
cargo run -- display README.md #顯示指定檔案
|
||||
````
|
||||
|
||||
###渲染 Markdown
|
||||
|
||||
```bash
|
||||
cargo run -- render README.md #輸出到 stdout
|
||||
cargo run -- render README.md -o output.html
|
||||
````
|
||||
|
||||
##測試
|
||||
|
||||
```bash
|
||||
cargo test #執行所有測試
|
||||
cargo test test_insert #執行特定測試
|
||||
````
|
||||
|
||||
## File Tree功能
|
||||
|
||||
### REST API
|
||||
|
||||
|路由 |方法 |功能 |
|
||||
|------|------|------|
|
||||
| `/api/v2/tree/:user_id` | GET | 取得檔案樹 |
|
||||
| `/api/v2/tree/:user_id/node` | POST | 建立節點 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id` | PUT/DELETE | 更新/刪除節點 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/move` | PUT | 移動節點 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/alias` | PATCH | 更新別名 |
|
||||
|
||||
###顯示模式
|
||||
|
||||
- `tree` - 樹狀顯示
|
||||
- `list` - 列表顯示
|
||||
- `grid_sm` - 小格狀顯示
|
||||
- `grid_lg` - 大格狀顯示
|
||||
|
||||
###範例
|
||||
|
||||
```bash
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=list
|
||||
````
|
||||
|
||||
## Demo資料
|
||||
|
||||
- `data/users/demo.sqlite` - 50節點範例資料
|
||||
- `data/cache/` -範例檔案
|
||||
|
||||
##開發
|
||||
|
||||
見 `AGENTS.md`詳細開發指南。
|
||||
|
||||
## CI/CD
|
||||
|
||||
使用 Gitea Actions:https://gitea.momentry.ddns.net
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
BIN
data/cache/29ffd4c12ef6481da6bee7ae4c36a89f.jpg
vendored
Normal file
BIN
data/cache/29ffd4c12ef6481da6bee7ae4c36a89f.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
100
data/cache/2c62f90aacc542a9bcfa0c65b63be02a.txt
vendored
Normal file
100
data/cache/2c62f90aacc542a9bcfa0c65b63be02a.txt
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
溝通/系統管理 | 標準乙太網路線 |
|
||||
4. 網路與連線配置
|
||||
4.3 Metadata/管理網路配置 (控制路徑)
|
||||
為確保 Apple XSAN 檔案系統的穩定運作、Metadata 交易的效率,以及系統管理和外部網路存取,需配置一個獨立的高速區域網路 (LAN)。
|
||||
• MDC 連接:
|
||||
• 2 台 XSAN MDC 選用 M4 Pro Mac Mini (內建 10GbE 乙太網路埠),直接透過 10G 網路線連接至 LAN Switch,用於 Metadata 存取與 MDC 叢集通訊。
|
||||
• MDC 規格確保: 64GB 統一記憶體及 1TB SSD 的配置,將提供充裕的資源和快速的內部儲存來執行 XSAN 服務,確保 MDC 的高穩定性和回應速度。
|
||||
|
||||
8. 設備清單 (新增章節)
|
||||
為提供清晰的採購依據,特列出本計劃書中所有核心設備的清單。
|
||||
項目
|
||||
型號/規格
|
||||
數量
|
||||
備註
|
||||
SAN 核心交換機
|
||||
Accusys SW16-G3
|
||||
1
|
||||
16-Port PCIe SAN Switch
|
||||
RAID 儲存系統
|
||||
Accusys ExaSAN A16S3-PS
|
||||
4
|
||||
16-Bay 機架式 RAID 系統
|
||||
數據儲存硬碟
|
||||
Seagate 雙磁臂企業級 SAS 18TB 或以上
|
||||
62
|
||||
56 顆數據 + 6 顆熱備援
|
||||
Metadata 專用 SSD
|
||||
1TB SAS SSD
|
||||
2
|
||||
僅安裝於 A16S3-PS-1 (Metadat好的,我將根據您的要求,明確兩台 XSAN Metadata Controller (MDC) 的硬體規格,即選用具備 64GB 統一記憶體、內建 10GbE 乙太網路埠及 1TB SSD 的 M4 Pro Mac Mini 型號。
|
||||
以下是計劃書中受影響的章節更新:
|
||||
|
||||
Accusys PCIe ExaSAN 儲存系統建置計劃書 (最終修訂版)
|
||||
2. 系統架構圖 (概念說明)
|
||||
元件
|
||||
數量
|
||||
規格細節 (修訂)
|
||||
功能
|
||||
連接方式
|
||||
主儲存系統 (RAID)
|
||||
4
|
||||
Accusys ExaSAN A16S3-PS
|
||||
數據儲存
|
||||
PCIe (QSFP) 光纖線 連接至 SW16-G3
|
||||
SAN 交換機 (Switch)
|
||||
1
|
||||
Accusys SW16-G3
|
||||
數據傳輸核心
|
||||
PCIe (QSFP)
|
||||
Metadata Controller (MDC)
|
||||
2
|
||||
Apple M4 Pro Mac Mini (64GB 統一記憶體, 1TB SSD)
|
||||
XSAN Metadata 控制
|
||||
PCIe: C2M 連接至 SW16-G3 LAN: 10G 乙太網路 連接至 獨立 LAN Switch
|
||||
編輯工作站 (Clients)
|
||||
10
|
||||
客戶自備 Mac 工作站
|
||||
數據讀寫
|
||||
PCIe: C2M 連接至 SW16-G3 LAN: 10G 乙太網路 連接至 獨立 LAN Switch
|
||||
Metadata/管理網路
|
||||
1
|
||||
16 埠 10G 乙太a LUN RAID 1)
|
||||
|
||||
|
||||
Metadata Controller
|
||||
Apple M4 Pro Mac Mini
|
||||
2
|
||||
64GB 統一記憶體、10GbE 乙太網路、1TB SSD
|
||||
|
||||
客戶端轉接器
|
||||
Accusys C2M
|
||||
12
|
||||
10 台工作站 + 2 台 MDC 使用
|
||||
|
||||
LAN Switch
|
||||
16 埠 10G 乙太網路交換機
|
||||
1
|
||||
專用於 Metadata 溝通與管理
|
||||
|
||||
SAN 連接線
|
||||
QSFP PCIe 光纖線 (依長度)
|
||||
10
|
||||
連接 10 台工作站至 SW16-G3
|
||||
|
||||
SAN 連接線
|
||||
QSFP PCIe 銅線 (短)
|
||||
2
|
||||
連接 2 台 MDC 至 SW16-G3
|
||||
|
||||
LAN 連接線
|
||||
Cat 6A 或以上 10G 網路線
|
||||
12
|
||||
連接所有工作站及 MDC 至 LAN Switch
|
||||
|
||||
工作站
|
||||
客戶自備 Mac 工作站
|
||||
10
|
||||
需具備 Thunderbolt 3/4 及 10GbE 網路埠
|
||||
|
||||
(其餘章節內容保持不變。)
|
||||
BIN
data/users/demo.sqlite
Normal file
BIN
data/users/demo.sqlite
Normal file
Binary file not shown.
753
docs/api.yaml
Normal file
753
docs/api.yaml
Normal file
@@ -0,0 +1,753 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: MarkBase API
|
||||
description: Momentry Display Engine - Markdown渲染與檔案樹管理系統
|
||||
version: 0.1.0
|
||||
contact:
|
||||
name: MarkBase Team
|
||||
|
||||
servers:
|
||||
- url: http://localhost:11438
|
||||
description: 本地開發伺服器
|
||||
- url: https://gitea.momentry.ddns.net
|
||||
description: 生產伺服器
|
||||
|
||||
tags:
|
||||
- name: Display
|
||||
description: 內容顯示與渲染
|
||||
- name: File Tree
|
||||
description: 檔案樹管理
|
||||
- name: Files
|
||||
description: 檔案操作
|
||||
- name: System
|
||||
description: 系統狀態與控制
|
||||
- name: Audio
|
||||
description: macOS音訊控制
|
||||
|
||||
paths:
|
||||
# Display相關路由
|
||||
/:
|
||||
get:
|
||||
tags: [Display]
|
||||
summary: 主頁顯示
|
||||
description: 返回當前顯示的HTML內容
|
||||
responses:
|
||||
'200':
|
||||
description: HTML內容
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/display:
|
||||
post:
|
||||
tags: [Display]
|
||||
summary: 更新顯示內容
|
||||
description: 根據內容類型更新顯示內容
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [md, markdown, json, url, video, image, html]
|
||||
description: 內容類型
|
||||
data:
|
||||
type: string
|
||||
description: 內容資料
|
||||
file:
|
||||
type: string
|
||||
description:檔案路徑
|
||||
url:
|
||||
type: string
|
||||
description: URL地址
|
||||
html:
|
||||
type: string
|
||||
description: HTML內容
|
||||
step_id:
|
||||
type: string
|
||||
step_num:
|
||||
type: integer
|
||||
step_total:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
/version:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: 取得版本號
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
v:
|
||||
type: integer
|
||||
|
||||
/status:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: 取得狀態
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
paused:
|
||||
type: boolean
|
||||
step:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
voice:
|
||||
type: string
|
||||
|
||||
/body:
|
||||
get:
|
||||
tags: [Display]
|
||||
summary: 取得內容body
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/labels:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: 取得標籤列表
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
post:
|
||||
tags: [System]
|
||||
summary:更新標籤列表
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
# Audio相關路由
|
||||
/devices:
|
||||
get:
|
||||
tags: [Audio]
|
||||
summary: 取得音訊裝置
|
||||
description: 列出所有音訊輸入/輸出裝置(macOS)
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
output:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
input:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
current_out:
|
||||
type: string
|
||||
current_in:
|
||||
type: string
|
||||
|
||||
/volume:
|
||||
get:
|
||||
tags: [Audio]
|
||||
summary: 取得音量
|
||||
description: 取得當前音量級別(macOS)
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
level:
|
||||
type: integer
|
||||
|
||||
# Command相關路由
|
||||
/command:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: 取得指令隊列
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
cmd:
|
||||
type: string
|
||||
val:
|
||||
type: string
|
||||
post:
|
||||
tags: [System]
|
||||
summary: 提交指令
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cmd:
|
||||
type: string
|
||||
val:
|
||||
type: string
|
||||
out:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
# File Tree相關路由
|
||||
/api/v2/tree/{user_id}:
|
||||
get:
|
||||
tags: [File Tree]
|
||||
summary: 取得檔案樹
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: mode
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [tree, list, grid_sm, grid_lg]
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FileNode'
|
||||
delete:
|
||||
tags: [File Tree]
|
||||
summary: 刪除所有節點
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
deleted:
|
||||
type: integer
|
||||
|
||||
/api/v2/tree/{user_id}/node:
|
||||
post:
|
||||
tags: [File Tree]
|
||||
summary: 建立節點
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateNodeRequest'
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
node_id:
|
||||
type: string
|
||||
|
||||
/api/v2/tree/{user_id}/node/{node_id}:
|
||||
put:
|
||||
tags: [File Tree]
|
||||
summary: 更新節點
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
- name: node_id
|
||||
in: path
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateNodeRequest'
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
delete:
|
||||
tags: [File Tree]
|
||||
summary: 刪除節點
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
- name: node_id
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
/api/v2/tree/{user_id}/node/{node_id}/move:
|
||||
put:
|
||||
tags: [File Tree]
|
||||
summary: 移動節點
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
- name: node_id
|
||||
in: path
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
parent_id:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
/api/v2/tree/{user_id}/node/{node_id}/alias:
|
||||
patch:
|
||||
tags: [File Tree]
|
||||
summary: 更新別名
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
- name: node_id
|
||||
in: path
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
/api/v2/tree/{user_id}/restore:
|
||||
post:
|
||||
tags: [File Tree]
|
||||
summary: 從外部API恢復檔案樹
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
deleted:
|
||||
type: integer
|
||||
imported:
|
||||
type: integer
|
||||
|
||||
/api/v2/modes:
|
||||
get:
|
||||
tags: [File Tree]
|
||||
summary: 取得顯示模式列表
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
modes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DisplayMode'
|
||||
|
||||
# Files相關路由
|
||||
/api/v2/dupes/{user_id}:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 取得重複檔案
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/api/v2/unregister/{file_uuid}:
|
||||
post:
|
||||
tags: [Files]
|
||||
summary: 取消註冊檔案
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/api/v2/upload/{user_id}:
|
||||
post:
|
||||
tags: [Files]
|
||||
summary: 上傳檔案
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
filename:
|
||||
type: string
|
||||
file_uuid:
|
||||
type: string
|
||||
sha256:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
|
||||
/api/v2/render/{file_uuid}:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 渲染檔案
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/api/v2/render/{file_uuid}/body:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 取得檔案渲染內容
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/api/v2/files/{file_uuid}/info:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 取得檔案資訊
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FileInfo'
|
||||
|
||||
/api/v2/files/{file_uuid}/probe:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 取得檔案探測資訊
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/api/v2/files/{file_uuid}/stream:
|
||||
get:
|
||||
tags: [Files]
|
||||
summary: 串流檔案
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
/api/v2/files/{file_uuid}/locations:
|
||||
post:
|
||||
tags: [Files]
|
||||
summary: 新增檔案位置
|
||||
parameters:
|
||||
- name: file_uuid
|
||||
in: path
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
location:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
|
||||
components:
|
||||
schemas:
|
||||
FileNode:
|
||||
type: object
|
||||
properties:
|
||||
node_id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
aliases:
|
||||
type: object
|
||||
file_uuid:
|
||||
type: string
|
||||
sha256:
|
||||
type: string
|
||||
parent_id:
|
||||
type: string
|
||||
node_type:
|
||||
type: string
|
||||
enum: [folder, file]
|
||||
icon:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
bg_color:
|
||||
type: string
|
||||
file_size:
|
||||
type: integer
|
||||
registered_at:
|
||||
type: string
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
CreateNodeRequest:
|
||||
type: object
|
||||
required: [label]
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
node_type:
|
||||
type: string
|
||||
enum: [folder, file]
|
||||
parent_id:
|
||||
type: string
|
||||
file_uuid:
|
||||
type: string
|
||||
sha256:
|
||||
type: string
|
||||
file_size:
|
||||
type: integer
|
||||
file_type:
|
||||
type: string
|
||||
|
||||
UpdateNodeRequest:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
bg_color:
|
||||
type: string
|
||||
|
||||
DisplayMode:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
sort_options:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SortOption'
|
||||
filter_options:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FilterOption'
|
||||
|
||||
SortOption:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
|
||||
FilterOption:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
|
||||
FileInfo:
|
||||
type: object
|
||||
properties:
|
||||
file_uuid:
|
||||
type: string
|
||||
original_name:
|
||||
type: string
|
||||
file_size:
|
||||
type: integer
|
||||
file_type:
|
||||
type: string
|
||||
registered_at:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
413
docs/filetree.md
Normal file
413
docs/filetree.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# File Tree Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
File Tree是MarkBase的核心模組,提供檔案樹管理功能。
|
||||
|
||||
**Location:** `src/filetree/`
|
||||
|
||||
**Total Lines:** 1234行
|
||||
|
||||
---
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/filetree/
|
||||
├── mod.rs (553行) -核心CRUD操作
|
||||
├── convert.rs (253行) -檔案轉換功能
|
||||
├── mode.rs (43行) - DisplayMode trait定義
|
||||
├── node.rs (82行) -節點資料結構
|
||||
└── modes/
|
||||
├── tree.rs (57行) -樹狀顯示模式
|
||||
├── list.rs (87行) -列表顯示模式
|
||||
├── grid_sm.rs (72行) -小格狀顯示模式
|
||||
├── grid_lg.rs (83行) -大格狀顯示模式
|
||||
└── mod.rs (4行) -模式匯出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Design
|
||||
|
||||
### SQLite Tables
|
||||
|
||||
**Location:** `data/users/<user_id>.sqlite`
|
||||
|
||||
|Table |Purpose |
|
||||
|------|--------|
|
||||
| file_registry |檔案註冊資訊 |
|
||||
| file_nodes |檔案樹節點 |
|
||||
| file_locations |檔案位置記錄 |
|
||||
|
||||
### file_nodes Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE file_nodes (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL DEFAULT '{}',
|
||||
file_uuid TEXT,
|
||||
sha256 TEXT,
|
||||
parent_id TEXT,
|
||||
children_json TEXT NOT NULL DEFAULT '[]',
|
||||
node_type TEXT NOT NULL DEFAULT 'folder',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
bg_color TEXT,
|
||||
file_size INTEGER,
|
||||
registered_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
### Node Types
|
||||
|
||||
- **folder** -資料夾節點(可包含子節點)
|
||||
- **file** -檔案節點(指向實體檔案)
|
||||
|
||||
---
|
||||
|
||||
## Public API(13 Functions)
|
||||
|
||||
### CRUD Operations(mod.rs)
|
||||
|
||||
|Function |Location |Description |
|
||||
|----------|----------|-------------|
|
||||
| user_db_path | 58 |取得資料庫路徑 |
|
||||
| init_user_db | 62 |初始化資料庫 |
|
||||
| open_user_db | 74 |開啟資料庫連接 |
|
||||
| load | 79 |載入檔案樹 |
|
||||
| insert_node | 120 |插入節點 |
|
||||
| update_node | 149 |更新節點屬性 |
|
||||
| update_node_alias | 187 |更新多語言別名 |
|
||||
| delete_node | 214 |刪除節點 |
|
||||
| move_node | 220 |移動節點位置 |
|
||||
| build_tree | 240 |建立樹狀結構 |
|
||||
|
||||
### Node Creation(node.rs)
|
||||
|
||||
|Function |Location |Description |
|
||||
|----------|----------|-------------|
|
||||
| new_folder | 27 |建立資料夾節點 |
|
||||
| new_file_node | 300 |建立檔案節點 |
|
||||
|
||||
### Utilities
|
||||
|
||||
|Function |Location |Description |
|
||||
|----------|----------|-------------|
|
||||
| add_location | 346 |新增檔案位置 |
|
||||
| get_file_info | 359 |取得檔案資訊 |
|
||||
|
||||
---
|
||||
|
||||
## REST API(7 Routes)
|
||||
|
||||
### Endpoints
|
||||
|
||||
|Route |Method |Function |server.rs |
|
||||
|------|--------|----------|-----------|
|
||||
| `/api/v2/tree/:user_id` | GET | get_tree | 61 |
|
||||
| `/api/v2/tree/:user_id` | DELETE | delete_all_nodes | 64 |
|
||||
| `/api/v2/tree/:user_id/node` | POST | create_node | 62 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id` | PUT | update_node | 63 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id` | DELETE | delete_node | 63 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/move` | PUT | move_node | 71 |
|
||||
| `/api/v2/tree/:user_id/node/:node_id/alias` | PATCH | update_alias | 72 |
|
||||
| `/api/v2/tree/:user_id/restore` | POST | restore_tree | 65 |
|
||||
|
||||
### Query Parameters
|
||||
|
||||
- `mode` -顯示模式(tree, list, grid_sm, grid_lg)
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
#取得檔案樹(樹狀顯示)
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
|
||||
#取得檔案樹(列表顯示)
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=list
|
||||
|
||||
#建立節點
|
||||
curl -X POST http://localhost:11438/api/v2/tree/demo/node \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"NewFolder", "node_type":"folder"}'
|
||||
|
||||
#更新別名
|
||||
curl -X PATCH http://localhost:11438/api/v2/tree/demo/node/<node_id>/alias \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"lang":"zh_tw", "value":"新資料夾"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DisplayMode Trait
|
||||
|
||||
### Definition(mode.rs:19)
|
||||
|
||||
```rust
|
||||
pub trait DisplayMode: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn render(&self, tree: &FileTree) -> Value;
|
||||
fn sort_options(&self) -> Vec<SortOption>;
|
||||
fn filter_options(&self) -> Vec<FilterOption>;
|
||||
}
|
||||
```
|
||||
|
||||
### Implementations
|
||||
|
||||
|Mode |File |Purpose |
|
||||
|------|------|---------|
|
||||
| Tree | modes/tree.rs |層級樹狀結構顯示 |
|
||||
| List | modes/list.rs |簡潔列表顯示 |
|
||||
| GridSm | modes/grid_sm.rs |緊湊格狀顯示 |
|
||||
| GridLg | modes/grid_lg.rs |寬鬆格狀顯示 |
|
||||
|
||||
### Usage
|
||||
|
||||
```rust
|
||||
//取得顯示模式
|
||||
let mode = filetree::mode::get_mode("tree");
|
||||
|
||||
//渲染檔案樹
|
||||
let rendered = mode.render(&tree);
|
||||
|
||||
//取得排序選項
|
||||
let sort_options = mode.sort_options();
|
||||
|
||||
//取得過濾選項
|
||||
let filter_options = mode.filter_options();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Conversion(convert.rs)
|
||||
|
||||
### Supported Formats
|
||||
|
||||
|Tool |Formats |
|
||||
|------|---------|
|
||||
| textutil(macOS) | doc, docx, rtf |
|
||||
| macOS Tools | pages, key, numbers |
|
||||
| soffice/qlmanage | pptx, ppt, xlsx, xls, odt, epub |
|
||||
|
||||
### Functions
|
||||
|
||||
- `is_document_ext(ext)` - 檢查是否為文檔格式
|
||||
- `is_textutil_ext(ext)` - 檢查是否為textutil支援格式
|
||||
- `is_apple_format_ext(ext)` - 檢查是否為Apple格式
|
||||
- `get_cached_preview(file_uuid, ext)` - 生成緩存預覽
|
||||
|
||||
### Cache Directory
|
||||
|
||||
**Location:** `data/cache/`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Current Tests(7 Tests)
|
||||
|
||||
|Test |Status |Description |
|
||||
|------|--------|-------------|
|
||||
| test_init_and_load_empty_tree | ✅ OK |初始化空檔案樹 |
|
||||
| test_insert_and_load_node | ✅ OK |插入節點 |
|
||||
| test_update_node | ✅ OK |更新節點屬性 |
|
||||
| test_delete_node | ✅ OK |刪除節點 |
|
||||
| test_move_node | ✅ OK |移動節點位置 |
|
||||
| test_update_alias | ✅ OK |更新多語別別名 |
|
||||
| test_build_tree | ✅ OK |建立樹狀結構 |
|
||||
|
||||
### Missing Tests
|
||||
|
||||
- ❌ convert.rs -檔案轉換功能
|
||||
- ❌ modes/*.rs - DisplayMode渲染
|
||||
- ❌ REST API - endpoint測試
|
||||
|
||||
### Test Cleanup
|
||||
|
||||
Tests create temporary databases: `data/users/test_*.sqlite`
|
||||
|
||||
```bash
|
||||
#清理暫存資料庫
|
||||
rm data/users/test_*.sqlite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Examples
|
||||
|
||||
### Create Folder
|
||||
|
||||
```rust
|
||||
use crate::filetree::{FileTree, FileNode};
|
||||
|
||||
//建立資料夾節點
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
|
||||
//插入到資料庫
|
||||
let conn = FileTree::open_user_db("demo")?;
|
||||
let mut tree = FileTree::load(&conn, "demo")?;
|
||||
tree.insert_node(&conn, &folder)?;
|
||||
```
|
||||
|
||||
### Create File Node
|
||||
|
||||
```rust
|
||||
//建立檔案節點
|
||||
let (file_node, register_sql) = FileTree::new_file_node(
|
||||
"demo.mp4",
|
||||
"abc123def456...", // file_uuid
|
||||
Some("sha256hash"), // sha256
|
||||
"demo.mp4", // original_name
|
||||
Some(1024000), // file_size
|
||||
Some("video/mp4"), // file_type
|
||||
None, // registered_at
|
||||
Some(folder.node_id), // parent_id
|
||||
);
|
||||
|
||||
//插入到資料庫
|
||||
tree.insert_node(&conn, &file_node)?;
|
||||
```
|
||||
|
||||
### Load and Query
|
||||
|
||||
```rust
|
||||
//載入檔案樹
|
||||
let conn = FileTree::open_user_db("demo")?;
|
||||
let tree = FileTree::load(&conn, "demo")?;
|
||||
|
||||
//建立樹狀結構
|
||||
let roots = tree.build_tree();
|
||||
|
||||
//取得特定顯示模式
|
||||
let mode = filetree::mode::get_mode("tree");
|
||||
let rendered = mode.render(&tree);
|
||||
```
|
||||
|
||||
### Update Alias
|
||||
|
||||
```rust
|
||||
//更新多語言別名
|
||||
tree.update_node_alias(&conn, &node_id, "zh_tw", "影片")?;
|
||||
tree.update_node_alias(&conn, &node_id, "en_us", "Videos")?;
|
||||
tree.update_node_alias(&conn, &node_id, "ja_jp", "動画")?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Database
|
||||
|
||||
**Location:** `data/users/demo.sqlite`
|
||||
|
||||
**Statistics:**
|
||||
- Total Nodes: 50
|
||||
- Folders: 5
|
||||
- Files: 45
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
Home(根資料夾)
|
||||
├── Movies(9個檔案)
|
||||
│ ├── view7.mp4
|
||||
│ ├── Charade_YouTube_24fps.mp4
|
||||
│ └── ...
|
||||
├── Marketing(26個檔案)
|
||||
│ ├── Screenshot *.png
|
||||
│ ├── diagram-*.svg
|
||||
│ └── ...
|
||||
├── Cartoons(5個檔案)
|
||||
│ ├── animal11.jpg
|
||||
│ ├── animal10.jpg
|
||||
│ └── ...
|
||||
└── Other(4個檔案)
|
||||
├── people.jpg
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ server.rs │
|
||||
│ REST API Handlers(Axum) │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ filetree/mod.rs │
|
||||
│ FileTree CRUD Operations │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ data/users/<user_id>.sqlite │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────┐
|
||||
│ filetree/mode.rs │
|
||||
│ DisplayMode Trait │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ filetree/modes/*.rs │
|
||||
│ Tree, List, GridSm, GridLg │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Database Management
|
||||
|
||||
- Each user has independent database
|
||||
- Use UUID for node_id
|
||||
- Maintain parent-child relationships via parent_id
|
||||
- Update updated_at timestamp on modifications
|
||||
|
||||
### Performance
|
||||
|
||||
- Use spawn_blocking for SQLite operations(Axum async)
|
||||
- Cache file_tree in memory if frequently accessed
|
||||
- Batch operations when possible
|
||||
|
||||
### Security
|
||||
|
||||
- Validate node_type before insertion
|
||||
- Check parent_id existence before moving
|
||||
- Sanitize aliases_json input
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Planned Features
|
||||
|
||||
- Pagination for large file trees
|
||||
- Search and filter functionality
|
||||
- File versioning
|
||||
- Trash bin(soft delete)
|
||||
- Batch import/export
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
- Database connection pooling
|
||||
- Caching layer(Redis)
|
||||
- Async SQLite driver(tokio-rusqlite)
|
||||
|
||||
### Testing Coverage
|
||||
|
||||
- convert.rs tests
|
||||
- DisplayMode tests
|
||||
- REST API integration tests
|
||||
- Performance benchmarks
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-05-16
|
||||
**Version:** 1.0
|
||||
300
docs/gitea_runner_setup.md
Normal file
300
docs/gitea_runner_setup.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Gitea Actions Runner配置指南
|
||||
|
||||
##環境資訊
|
||||
|
||||
- **Gitea Server**: https://gitea.momentry.ddns.net
|
||||
- **Gitea版本**: 1.25.3(支援 Actions)
|
||||
- **Runner位置**:本機Mac
|
||||
- **目標**:實機測試 macOS音訊功能
|
||||
|
||||
---
|
||||
|
||||
##配置步驟
|
||||
|
||||
### 1. 取得 Runner Token
|
||||
|
||||
1.登入 Gitea: https://gitea.momentry.ddns.net
|
||||
2.進入 **Settings → Actions → Runners**
|
||||
3.建立新 Runner,複製 Token
|
||||
|
||||
**Token格式範例:**
|
||||
```
|
||||
A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.下載並安裝 Runner
|
||||
|
||||
**macOS ARM版本:**
|
||||
|
||||
```bash
|
||||
#下載 act_runner(Gitea官方Runner)
|
||||
wget https://dl.gitea.com/act_runner/latest/act_runner-darwin-arm64
|
||||
|
||||
#設定執行權限
|
||||
chmod +x act_runner-darwin-arm64
|
||||
|
||||
#移動到系統路徑
|
||||
sudo mv act_runner-darwin-arm64 /usr/local/bin/act_runner
|
||||
|
||||
#驗證安裝
|
||||
act_runner --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.註冊 Runner
|
||||
|
||||
**連接遠端 Gitea:**
|
||||
|
||||
```bash
|
||||
#註冊 Runner(使用步驟1取得的Token)
|
||||
act_runner register --instance https://gitea.momentry.ddns.net --token <YOUR_TOKEN>
|
||||
|
||||
#交互式配置
|
||||
#會提示輸入 Runner名稱、標籤等資訊
|
||||
```
|
||||
|
||||
**建議配置:**
|
||||
- Runner名稱:`macos-runner`
|
||||
- Labels:`macos-latest`
|
||||
- Runner group:default
|
||||
|
||||
---
|
||||
|
||||
### 4.啟動 Runner
|
||||
|
||||
**前景執行(測試用):**
|
||||
|
||||
```bash
|
||||
act_runner daemon
|
||||
```
|
||||
|
||||
**背景服務(macOS launchd):**
|
||||
|
||||
建立服務配置檔案:
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.gitea.runner.plist -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.gitea.runner</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/act_runner</string>
|
||||
<string>daemon</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/gitea-runner.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/gitea-runner.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**載入服務:**
|
||||
|
||||
```bash
|
||||
#載入服務
|
||||
launchctl load ~/Library/LaunchAgents/com.gitea.runner.plist
|
||||
|
||||
#驗證服務狀態
|
||||
launchctl list | grep gitea
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.驗證 Runner狀態
|
||||
|
||||
**在 Gitea網頁驗證:**
|
||||
|
||||
1.登入 Gitea
|
||||
2. Settings → Actions → Runners
|
||||
3.確認 Runner已連接(狀態為 green)
|
||||
|
||||
**在本機驗證:**
|
||||
|
||||
```bash
|
||||
#檢查 Runner狀態
|
||||
act_runner list
|
||||
|
||||
#檢查 Runner日誌
|
||||
tail -f /tmp/gitea-runner.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##必要環境配置
|
||||
|
||||
### Rust Toolchain
|
||||
|
||||
```bash
|
||||
#確認 Rust環境
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
#若未安裝
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
### SwitchAudioSource(音訊測試)
|
||||
|
||||
```bash
|
||||
#安裝
|
||||
brew install switchaudio-source
|
||||
|
||||
#驗證
|
||||
SwitchAudioSource -a
|
||||
```
|
||||
|
||||
###其他工具
|
||||
|
||||
```bash
|
||||
#確認 clippy
|
||||
cargo clippy --version
|
||||
|
||||
#確認 rustfmt
|
||||
cargo fmt -- --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow觸發測試
|
||||
|
||||
###觸發方式
|
||||
|
||||
**Push觸發:**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**PR觸發:**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/test
|
||||
git push origin feature/test
|
||||
#在 Gitea建立 Pull Request
|
||||
```
|
||||
|
||||
**查看執行結果:**
|
||||
|
||||
1.進入專案頁面
|
||||
2. Actions →查看workflow執行狀態
|
||||
|
||||
---
|
||||
|
||||
##常見問題
|
||||
|
||||
### Runner無法連接 Gitea
|
||||
|
||||
**檢查網路連接:**
|
||||
|
||||
```bash
|
||||
curl -I https://gitea.momentry.ddns.net
|
||||
```
|
||||
|
||||
**確認 Token正確:**
|
||||
|
||||
```bash
|
||||
act_runner list
|
||||
```
|
||||
|
||||
### Workflow執行失敗
|
||||
|
||||
**檢查日誌:**
|
||||
|
||||
```bash
|
||||
tail -f /tmp/gitea-runner.log
|
||||
```
|
||||
|
||||
**常見錯誤:**
|
||||
|
||||
- Rust未安裝 →安裝 Rust toolchain
|
||||
- SwitchAudioSource未安裝 → brew install
|
||||
- clippy警告 →修復代碼
|
||||
|
||||
---
|
||||
|
||||
## Runner維護
|
||||
|
||||
###更新 Runner
|
||||
|
||||
```bash
|
||||
#停止服務
|
||||
launchctl unload ~/Library/LaunchAgents/com.gitea.runner.plist
|
||||
|
||||
#下載新版本
|
||||
wget https://dl.gitea.com/act_runner/latest/act_runner-darwin-arm64
|
||||
|
||||
#重新安裝
|
||||
chmod +x act_runner-darwin-arm64
|
||||
sudo mv act_runner-darwin-arm64 /usr/local/bin/act_runner
|
||||
|
||||
#重新啟動
|
||||
launchctl load ~/Library/LaunchAgents/com.gitea.runner.plist
|
||||
```
|
||||
|
||||
###清理緩存
|
||||
|
||||
```bash
|
||||
#清理 cargo緩存
|
||||
cargo clean
|
||||
|
||||
#清理測試暫存檔
|
||||
rm -f data/users/test_*.sqlite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##進階配置
|
||||
|
||||
###多 Runner支援
|
||||
|
||||
若有多台機器,可配置多個 Runner:
|
||||
|
||||
```bash
|
||||
#第二台機器註冊
|
||||
act_runner register --instance https://gitea.momentry.ddns.net --token <TOKEN2>
|
||||
```
|
||||
|
||||
**使用不同 labels區分:**
|
||||
|
||||
- macos-runner-1: `macos-latest`
|
||||
- macos-runner-2: `macos-14`
|
||||
- linux-runner: `ubuntu-latest`
|
||||
|
||||
---
|
||||
|
||||
##安全性考量
|
||||
|
||||
### Token管理
|
||||
|
||||
- Token應妥善保存,避免暴露
|
||||
-定期更新 Token
|
||||
- 使用環境變數配置
|
||||
|
||||
###網路安全
|
||||
|
||||
- Gitea使用 HTTPS(已配置)
|
||||
- Runner使用 Token認證
|
||||
-僅連接信任的 Gitea server
|
||||
|
||||
---
|
||||
|
||||
##叢考資源
|
||||
|
||||
- [Gitea Actions官方文檔](https://docs.gitea.com/usage/actions/overview)
|
||||
- [act_runner GitHub](https://gitea.com/gitea/act_runner)
|
||||
- [GitHub Actions兼容性](https://docs.gitea.com/usage/actions/comparison)
|
||||
|
||||
---
|
||||
|
||||
**最後更新:2026-05-16**
|
||||
106
docs/runner_usage.md
Normal file
106
docs/runner_usage.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Gitea Runner使用指南(快速版)
|
||||
|
||||
## Runner已配置完成!
|
||||
|
||||
**Runner資訊:**
|
||||
-版本:v1.0.3
|
||||
- 名稱:accusys-Mac-mini-M4-2.local
|
||||
- ID:1
|
||||
-地址:https://gitea.momentry.ddns.net
|
||||
|
||||
---
|
||||
|
||||
## 啟動 Runner
|
||||
|
||||
### 方式A:前景執行(測試用)
|
||||
|
||||
```bash
|
||||
cd /Users/accusys/markbase
|
||||
./scripts/start_runner.sh
|
||||
```
|
||||
|
||||
或直接執行:
|
||||
|
||||
```bash
|
||||
cd /Users/accusys/markbase
|
||||
~/.local/bin/gitea-runner daemon
|
||||
```
|
||||
|
||||
### 方式B:背景服務(macOS launchd)
|
||||
|
||||
```bash
|
||||
#配置為系統服務
|
||||
./scripts/setup_launchd.sh
|
||||
|
||||
#管理命令
|
||||
launchctl load ~/Library/LaunchAgents/com.gitea.runner.plist #啟動
|
||||
launchctl unload ~/Library/LaunchAgents/com.gitea.runner.plist #停止
|
||||
launchctl list | grep gitea #狀態
|
||||
tail -f /tmp/gitea-runner.log #日誌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##驗證 Runner狀態
|
||||
|
||||
```bash
|
||||
./scripts/verify_runner.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##支持的Labels
|
||||
|
||||
- `macos-latest:host` -本機Mac執行(bare-metal)
|
||||
- `macos-arm64:host` -本機Mac執行(bare-metal)
|
||||
- `ubuntu-latest:docker` - Docker容器執行
|
||||
|
||||
---
|
||||
|
||||
##觸發Workflow測試
|
||||
|
||||
### Push觸發
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "test workflow"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 在Gitea查看結果
|
||||
|
||||
1.登入:https://gitea.momentry.ddns.net
|
||||
2.進入專案 → Actions →查看workflow執行狀態
|
||||
|
||||
---
|
||||
|
||||
##常見問題
|
||||
|
||||
**Q: Runner無法連接Gitea?**
|
||||
```bash
|
||||
curl -I https://gitea.momentry.ddns.net
|
||||
```
|
||||
|
||||
**Q: Workflow執行失敗?**
|
||||
```bash
|
||||
tail -f /tmp/gitea-runner.log
|
||||
tail -f /tmp/gitea-runner.err
|
||||
```
|
||||
|
||||
**Q: macOS功能測試失敗?**
|
||||
-確認SwitchAudioSource已安裝:`brew install switchaudio-source`
|
||||
-確認使用`runs-on: macos-latest`(bare-metal執行)
|
||||
|
||||
---
|
||||
|
||||
##Runner配置檔案
|
||||
|
||||
**位置:** `/Users/accusys/markbase/.runner`
|
||||
|
||||
**重要:**
|
||||
-不要手動刪除.runner檔案(會導致Runner重新註冊)
|
||||
- Runner token與註冊token不同(已自動生成)
|
||||
|
||||
---
|
||||
|
||||
**最後更新:2026-05-16**
|
||||
133
examples/demo_features.md
Normal file
133
examples/demo_features.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# MarkBase功能展示範例
|
||||
|
||||
## 一、Markdown渲染功能
|
||||
|
||||
### 1.1標題支援
|
||||
|
||||
# H1標題
|
||||
## H2標題
|
||||
### H3標題
|
||||
#### H4標題
|
||||
##### H5標題
|
||||
###### H6標題
|
||||
|
||||
### 1.2文字格式
|
||||
|
||||
**粗體文字**
|
||||
*斜體文字*
|
||||
**_粗體斜體_**
|
||||
~~刪除線~~
|
||||
|
||||
### 1.3列表功能
|
||||
|
||||
**無序列表:**
|
||||
-項目 1
|
||||
-項目 2
|
||||
-子項目 2.1
|
||||
-子項目 2.2
|
||||
-項目 3
|
||||
|
||||
**有序列表:**
|
||||
1.第一項
|
||||
2.第二項
|
||||
3.第三項
|
||||
|
||||
### 1.4表格支援
|
||||
|
||||
|功能 |狀態 |說明 |
|
||||
|------|------|------|
|
||||
|表格 |✅支援 |標準 Markdown表格 |
|
||||
| Footnote | ✅支援 |腳註功能[^1] |
|
||||
| Tasklist | ✅支援 |待辦事項列表 |
|
||||
|
||||
### 1.5代碼區塊
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, MarkBase!");
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
def hello():
|
||||
print("Hello, MarkBase!")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##二、檔案樹管理功能
|
||||
|
||||
### 2.1 REST API範例
|
||||
|
||||
```bash
|
||||
#取得檔案樹
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
|
||||
#建立資料夾節點
|
||||
curl -X POST http://localhost:11438/api/v2/tree/demo/node \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"Videos", "node_type":"folder"}'
|
||||
|
||||
#更新節點別名
|
||||
curl -X PATCH http://localhost:11438/api/v2/tree/demo/node/<node_id>/alias \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"lang":"zh_tw", "value":"影片"}'
|
||||
```
|
||||
|
||||
### 2.2顯示模式
|
||||
|
||||
|模式 |說明 |用途 |
|
||||
|------|------|------|
|
||||
| tree |樹狀顯示 |層級結構展示 |
|
||||
| list |列表顯示 |簡潔列表展示 |
|
||||
| grid_sm |小格狀顯示 |緊湊格狀展示 |
|
||||
| grid_lg |大格狀顯示 |寬鬆格狀展示 |
|
||||
|
||||
---
|
||||
|
||||
##三、macOS音訊功能
|
||||
|
||||
### 3.1音訊裝置API
|
||||
|
||||
```bash
|
||||
#列出所有音訊裝置
|
||||
curl http://localhost:11438/devices
|
||||
|
||||
#取得音量
|
||||
curl http://localhost:11438/volume
|
||||
|
||||
#切換音訊裝置(透過command API)
|
||||
curl -X POST http://localhost:11438/command \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"test_voice", "val":"zh_TW", "out":"Display Audio"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##四、Mermaid圖表支援
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[MarkBase入口] --> B[CLI解析]
|
||||
B --> C[Display伺服器]
|
||||
B --> D[Render工具]
|
||||
C --> E[REST API]
|
||||
E --> F[檔案樹管理]
|
||||
E --> G[音訊控制]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##五、多語別別名範例
|
||||
|
||||
|節點 |英文 |中文 |日文 |
|
||||
|------|------|------|------|
|
||||
| Videos | Videos |影片 |動画 |
|
||||
| Movies | Movies |電影 |映画 |
|
||||
| Marketing | Marketing |行銷 |マーケティング |
|
||||
|
||||
---
|
||||
|
||||
**MarkBase展示範例完成!**
|
||||
|
||||
[^1]: 這是一個腳註範例。
|
||||
248
examples/developer_quickstart.md
Normal file
248
examples/developer_quickstart.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 開發者快速展示指南
|
||||
|
||||
##目的
|
||||
|
||||
本文件提供MarkBase核心功能的快速展示範例。
|
||||
|
||||
---
|
||||
|
||||
##一、基礎展示(3分鐘)
|
||||
|
||||
### 1.1啟動伺服器
|
||||
|
||||
```bash
|
||||
cargo run -- display
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-伺服器啟動在 http://localhost:11438
|
||||
-瀏覽器自動開啟
|
||||
-顯示 MarkBase主頁
|
||||
|
||||
### 1.2顯示範例文件
|
||||
|
||||
```bash
|
||||
cargo run -- display examples/demo_features.md
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-顯示完整功能展示文檔
|
||||
-支援表格、列表、代碼區塊
|
||||
|
||||
---
|
||||
|
||||
##二、檔案樹展示(5分鐘)
|
||||
|
||||
### 2.1查看 Demo資料
|
||||
|
||||
```bash
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=tree
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-返回 JSON格式的檔案樹結構
|
||||
- 50個節點(5 Folder + 45 File)
|
||||
|
||||
### 2.2不同顯示模式
|
||||
|
||||
```bash
|
||||
#列表模式
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=list
|
||||
|
||||
#小格狀模式
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=grid_sm
|
||||
|
||||
#大格狀模式
|
||||
curl http://localhost:11438/api/v2/tree/demo?mode=grid_lg
|
||||
```
|
||||
|
||||
### 2.3建立新節點
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:11438/api/v2/tree/demo/node \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"展示資料夾", "node_type":"folder"}'
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-返回 node_id
|
||||
-狀態碼 201(CREATED)
|
||||
|
||||
---
|
||||
|
||||
##三、音訊功能展示(macOS)
|
||||
|
||||
### 3.1列出音訊裝置
|
||||
|
||||
```bash
|
||||
curl http://localhost:11438/devices
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-列出所有輸出裝置
|
||||
-列出所有輸入裝置
|
||||
-顯示當前選用裝置
|
||||
|
||||
### 3.2語音測試
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:11438/command \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"cmd":"test_voice", "val":"zh_TW"}'
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-播放中文語音「語音測試一二三」
|
||||
|
||||
---
|
||||
|
||||
##四、渲染功能展示
|
||||
|
||||
### 4.1渲染 Markdown檔案
|
||||
|
||||
```bash
|
||||
cargo run -- render examples/demo_features.md
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-輸出 HTML到 stdout
|
||||
|
||||
### 4.2輸出到檔案
|
||||
|
||||
```bash
|
||||
cargo run -- render examples/demo_features.md -o demo_output.html
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-生成 demo_output.html檔案
|
||||
|
||||
---
|
||||
|
||||
##五、測試展示
|
||||
|
||||
### 5.1執行所有測試
|
||||
|
||||
```bash
|
||||
cargo test --all
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
- 62個測試全部通過
|
||||
-執行時間 ~1秒
|
||||
|
||||
### 5.2特定模組測試
|
||||
|
||||
```bash
|
||||
cargo test filetree
|
||||
cargo test modes
|
||||
cargo test api_logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##六、代碼品質展示
|
||||
|
||||
### 6.1格式化檢查
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-無差異(代碼已格式化)
|
||||
|
||||
### 6.2 Clippy檢查
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-少數次要警告(可忽略)
|
||||
-主要警告已修復
|
||||
|
||||
---
|
||||
|
||||
##七、 Runner展示
|
||||
|
||||
### 7.1啟動 Runner
|
||||
|
||||
```bash
|
||||
cd /Users/accusys/markbase
|
||||
./scripts/start_runner.sh
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
- Runner連接遠端 Gitea
|
||||
-日誌輸出到/tmp/gitea-runner.log
|
||||
|
||||
### 7.2驗證 Runner狀態
|
||||
|
||||
```bash
|
||||
./scripts/verify_runner.sh
|
||||
```
|
||||
|
||||
**預期結果:**
|
||||
-顯示 Runner配置狀態
|
||||
-顯示環境準備度
|
||||
|
||||
---
|
||||
|
||||
##八、展示腳本整合
|
||||
|
||||
###完整展示流程
|
||||
|
||||
```bash
|
||||
# 1.建構專案
|
||||
cargo build
|
||||
|
||||
# 2.啟動伺服器
|
||||
cargo run -- display examples/demo_features.md
|
||||
|
||||
# 3.測試檔案樹API
|
||||
curl http://localhost:11438/api/v2/tree/demo
|
||||
|
||||
# 4.測試音訊功能
|
||||
curl http://localhost:11438/devices
|
||||
|
||||
# 5.執行測試
|
||||
cargo test --all
|
||||
|
||||
# 6.代碼品質檢查
|
||||
cargo clippy
|
||||
|
||||
# 7. Runner驗證
|
||||
./scripts/verify_runner.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##九、展示檔案位置
|
||||
|
||||
```
|
||||
examples/
|
||||
├── demo_features.md # 功能展示文檔
|
||||
├── developer_quickstart.md #開發者快速指南
|
||||
├── sample.md #基本範例
|
||||
├── sample.json # JSON範例
|
||||
└── files/
|
||||
├── example_video.mp4 #影片範例(待補充)
|
||||
├── example_audio.mp3 #音訊範例(待補充)
|
||||
└── example_image.jpg #圖片範例(待補充)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##十、展示檢查清單
|
||||
|
||||
展示前確認:
|
||||
|
||||
- [ ] cargo build成功
|
||||
- [ ] cargo test全部通過
|
||||
- [ ] SwitchAudioSource已安裝(macOS)
|
||||
- [ ] demo.sqlite資料完整(50節點)
|
||||
- [ ] Runner已啟動(若展示 CI/CD)
|
||||
|
||||
---
|
||||
|
||||
**展示指南完成!**
|
||||
42
examples/sample.json
Normal file
42
examples/sample.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "MarkBase範例 JSON",
|
||||
"version": "1.0.0",
|
||||
"features": [
|
||||
{
|
||||
"name": "Markdown渲染",
|
||||
"status": "active",
|
||||
"description": "支援完整 Markdown語法"
|
||||
},
|
||||
{
|
||||
"name": "檔案樹管理",
|
||||
"status": "active",
|
||||
"description": "SQLite持久化檔案樹"
|
||||
},
|
||||
{
|
||||
"name": "REST API",
|
||||
"status": "active",
|
||||
"description": "18+ RESTful路由"
|
||||
},
|
||||
{
|
||||
"name": "音訊控制",
|
||||
"status": "active",
|
||||
"description": "macOS音訊裝置管理"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"port": 11438,
|
||||
"db_dir": "data/users",
|
||||
"cache_dir": "data/cache"
|
||||
},
|
||||
"display_modes": [
|
||||
"tree",
|
||||
"list",
|
||||
"grid_sm",
|
||||
"grid_lg"
|
||||
],
|
||||
"metadata": {
|
||||
"author": "MarkBase Team",
|
||||
"created_at": "2026-05-16",
|
||||
"updated_at": "2026-05-16"
|
||||
}
|
||||
}
|
||||
100
examples/sample.md
Normal file
100
examples/sample.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# MarkBase範例文件
|
||||
|
||||
## 概述
|
||||
|
||||
這是一個範例 Markdown檔案,展示 MarkBase的渲染能力。
|
||||
|
||||
## 功能展示
|
||||
|
||||
###表格支援
|
||||
|
||||
|功能 |狀態 |說明 |
|
||||
|------|------|------|
|
||||
|表格 |✅支援 |標準 Markdown表格 |
|
||||
| Footnote | ✅支援 |腳註功能[^1] |
|
||||
| Tasklist | ✅支援 |待辦事項列表 |
|
||||
| Strikethrough | ✅支援 |~~刪除線~~ |
|
||||
|
||||
###待辦事項列表
|
||||
|
||||
- [x]已完成項目
|
||||
- [ ]待完成項目
|
||||
- [ ]另一個待完成項目
|
||||
|
||||
###代碼區塊
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, MarkBase!");
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
def hello():
|
||||
print("Hello, MarkBase!")
|
||||
```
|
||||
|
||||
###標題層級
|
||||
|
||||
# H1標題
|
||||
## H2標題
|
||||
### H3標題
|
||||
#### H4標題
|
||||
##### H5標題
|
||||
###### H6標題
|
||||
|
||||
###列表
|
||||
|
||||
**無序列表:**
|
||||
-項目 1
|
||||
- 項目 2
|
||||
-子項目 2.1
|
||||
- 子項目 2.2
|
||||
-項目 3
|
||||
|
||||
**有序列表:**
|
||||
1.第一項
|
||||
2. 第二項
|
||||
3. 第三項
|
||||
|
||||
###連結與圖片
|
||||
|
||||
**連結:**
|
||||
- [MarkBase GitHub](https://github.com/example/markbase)
|
||||
- [Rust官方](https://www.rust-lang.org/)
|
||||
|
||||
**圖片:**
|
||||
(需提供實際圖片路徑)
|
||||
|
||||
###引用
|
||||
|
||||
> 這是一個引用區塊。
|
||||
>
|
||||
> 可以包含多行文字。
|
||||
|
||||
###分隔線
|
||||
|
||||
---
|
||||
|
||||
###強調
|
||||
|
||||
**粗體文字**
|
||||
*斜體文字*
|
||||
**_粗體斜體_**
|
||||
~~刪除線~~
|
||||
|
||||
---
|
||||
|
||||
## Mermaid圖表(支援)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[開始] --> B[處理]
|
||||
B --> C{決策}
|
||||
C -->|是| D[結束]
|
||||
C -->|否| B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[^1]: 這是一個腳註範例。
|
||||
28
scripts/com.gitea.runner.plist
Normal file
28
scripts/com.gitea.runner.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.gitea.runner</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/accusys/.local/bin/gitea-runner</string>
|
||||
<string>daemon</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/accusys/markbase</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/gitea-runner.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/gitea-runner.err</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/Users/accusys/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
116
scripts/demo_full.sh
Executable file
116
scripts/demo_full.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MarkBase完整展示腳本
|
||||
|
||||
echo "=== MarkBase功能展示開始 ==="
|
||||
echo ""
|
||||
|
||||
#顏色定義
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 1.建構專案
|
||||
echo -e "${BLUE}Step 1:建構專案...${NC}"
|
||||
cargo build --release
|
||||
echo -e "${GREEN}✅建構完成${NC}"
|
||||
echo ""
|
||||
|
||||
# 2.執行測試
|
||||
echo -e "${BLUE}Step 2:執行測試...${NC}"
|
||||
cargo test --all 2>&1 | grep -E "test result" | tail -1
|
||||
echo -e "${GREEN}✅測試完成${NC}"
|
||||
echo ""
|
||||
|
||||
# 3.代碼品質檢查
|
||||
echo -e "${BLUE}Step 3:代碼品質檢查...${NC}"
|
||||
cargo fmt -- --check
|
||||
cargo clippy --all-targets 2>&1 | grep -E "warning.*generated" | tail -3
|
||||
echo -e "${GREEN}✅代碼品質檢查完成${NC}"
|
||||
echo ""
|
||||
|
||||
# 4.檔案樹API展示
|
||||
echo -e "${BLUE}Step 4:檔案樹API展示...${NC}"
|
||||
echo "取得檔案樹(demo.sqlite):"
|
||||
curl -s http://localhost:11438/api/v2/tree/demo | jq '.nodes | length'
|
||||
echo ""
|
||||
|
||||
echo "顯示模式列表:"
|
||||
curl -s http://localhost:11438/api/v2/modes | jq '.modes[].name'
|
||||
echo -e "${GREEN}✅檔案樹API展示完成${NC}"
|
||||
echo ""
|
||||
|
||||
# 5.音訊功能展示(macOS)
|
||||
echo -e "${BLUE}Step 5:音訊功能展示(macOS)...${NC}"
|
||||
echo "音訊裝置列表:"
|
||||
curl -s http://localhost:11438/devices | jq '.output'
|
||||
echo ""
|
||||
|
||||
echo "當前音量:"
|
||||
curl -s http://localhost:11438/volume | jq '.level'
|
||||
echo -e "${GREEN}✅音訊功能展示完成${NC}"
|
||||
echo ""
|
||||
|
||||
# 6. Runner狀態檢查
|
||||
echo -e "${BLUE}Step 6: Runner狀態檢查...${NC}"
|
||||
if [ -f .runner ]; then
|
||||
echo "Runner配置:"
|
||||
cat .runner | jq '{id, name, address, labels}'
|
||||
echo -e "${GREEN}✅ Runner已配置${NC}"
|
||||
else
|
||||
echo "⚠️ Runner未配置"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7.文檔完整性檢查
|
||||
echo -e "${BLUE}Step 7:文檔完整性檢查...${NC}"
|
||||
echo "核心文檔:"
|
||||
ls -la README.md AGENTS.md CHANGELOG.md CONTRIBUTING.md | awk '{print $9, $5}'
|
||||
echo ""
|
||||
|
||||
echo "API文檔:"
|
||||
ls -la docs/api.yaml | awk '{print $9, $5}'
|
||||
echo -e "${GREEN}✅文檔完整${NC}"
|
||||
echo ""
|
||||
|
||||
# 8.範例檔案檢查
|
||||
echo -e "${BLUE}Step 8:範例檔案檢查...${NC}"
|
||||
echo "範例檔案:"
|
||||
ls -la examples/*.md | awk '{print $9, $5}'
|
||||
echo ""
|
||||
|
||||
echo "範例資料庫:"
|
||||
ls -la data/users/demo.sqlite | awk '{print $9, $5}'
|
||||
echo ""
|
||||
|
||||
echo "緩存檔案:"
|
||||
ls -la data/cache/ | tail -3
|
||||
echo -e "${GREEN}✅範例檔案完整${NC}"
|
||||
echo ""
|
||||
|
||||
# 9.測試覆蓋率總結
|
||||
echo -e "${BLUE}Step 9:測試覆蓋率總結...${NC}"
|
||||
echo "測試檔案:"
|
||||
find tests -name "*.rs" -exec wc -l {} \; | awk '{sum+=$1} END {print "總行數: " sum}'
|
||||
echo ""
|
||||
|
||||
echo "測試數量:"
|
||||
cargo test --all 2>&1 | grep -E "running.*tests" | grep -o '[0-9]*' | awk '{sum+=$1} END {print "總測試數: " sum}'
|
||||
echo -e "${GREEN}✅測試覆蓋率良好${NC}"
|
||||
echo ""
|
||||
|
||||
#展示完成總結
|
||||
echo -e "${GREEN}=== MarkBase功能展示完成 ===${NC}"
|
||||
echo ""
|
||||
echo "展示摘要:"
|
||||
echo " ✅建構成功"
|
||||
echo " ✅測試全部通過"
|
||||
echo " ✅代碼品質良好"
|
||||
echo " ✅ API功能正常"
|
||||
echo " ✅ macOS音訊功能正常"
|
||||
echo " ✅文檔完整"
|
||||
echo " ✅範例檔案完整"
|
||||
echo ""
|
||||
echo "準備執行完整展示,請執行:"
|
||||
echo " cargo run -- display examples/demo_features.md"
|
||||
echo ""
|
||||
28
scripts/setup_launchd.sh
Executable file
28
scripts/setup_launchd.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# macOS launchd服務配置腳本
|
||||
|
||||
PLIST_FILE=/Users/accusys/markbase/scripts/com.gitea.runner.plist
|
||||
LAUNCH_AGENTS=~/Library/LaunchAgents
|
||||
|
||||
echo "配置 Gitea Runner為 macOS系統服務..."
|
||||
|
||||
#複製plist到LaunchAgents
|
||||
cp "$PLIST_FILE" "$LAUNCH_AGENTS/com.gitea.runner.plist"
|
||||
|
||||
#載入服務
|
||||
launchctl load "$LAUNCH_AGENTS/com.gitea.runner.plist"
|
||||
|
||||
#驗證服務狀態
|
||||
echo "驗證服務狀態..."
|
||||
launchctl list | grep gitea
|
||||
|
||||
echo ""
|
||||
echo "Runner服務已配置!"
|
||||
echo ""
|
||||
echo "管理命令:"
|
||||
echo " 啟動:launchctl load ~/Library/LaunchAgents/com.gitea.runner.plist"
|
||||
echo " 停止:launchctl unload ~/Library/LaunchAgents/com.gitea.runner.plist"
|
||||
echo " 狀態:launchctl list | grep gitea"
|
||||
echo " 日誌:tail -f /tmp/gitea-runner.log"
|
||||
echo ""
|
||||
32
scripts/start_runner.sh
Executable file
32
scripts/start_runner.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MarkBase Gitea Runner啟動腳本
|
||||
|
||||
RUNNER_BIN=~/.local/bin/gitea-runner
|
||||
CONFIG_FILE=/Users/accusys/markbase/.runner
|
||||
|
||||
echo "啟動 Gitea Runner..."
|
||||
echo "Runner版本:v1.0.3"
|
||||
echo "Runner名稱:accusys-Mac-mini-M4-2.local"
|
||||
echo "Gitea地址:https://gitea.momentry.ddns.net"
|
||||
|
||||
#檢查Runner配置是否存在
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "錯誤:Runner配置檔案不存在"
|
||||
echo "請先執行:gitea-runner register --instance https://gitea.momentry.ddns.net --token <YOUR_TOKEN>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#檢查Runner二進制檔案
|
||||
if [ ! -f "$RUNNER_BIN" ]; then
|
||||
echo "錯誤:Runner未安裝"
|
||||
echo "請先下載:curl -L -o ~/.local/bin/gitea-runner https://gitea.com/gitea/runner/releases/download/v1.0.3/gitea-runner-1.0.3-darwin-arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#啟動Runner daemon
|
||||
echo "Runner正在啟動..."
|
||||
cd /Users/accusys/markbase
|
||||
$RUNNER_BIN daemon
|
||||
|
||||
echo "Runner已停止"
|
||||
87
scripts/verify_runner.sh
Executable file
87
scripts/verify_runner.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Runner狀態驗證腳本
|
||||
|
||||
echo "=== Gitea Runner狀態驗證 ==="
|
||||
echo ""
|
||||
|
||||
#檢查Runner二進制檔案
|
||||
echo "1. 檢查Runner安裝..."
|
||||
if [ -f ~/.local/bin/gitea-runner ]; then
|
||||
echo "✅ Runner已安裝:~/.local/bin/gitea-runner"
|
||||
VERSION=$(~/.local/bin/gitea-runner --version)
|
||||
echo " 版本:$VERSION"
|
||||
else
|
||||
echo "❌ Runner未安裝"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#檢查Runner配置
|
||||
echo "2. 檢查Runner配置..."
|
||||
if [ -f /Users/accusys/markbase/.runner ]; then
|
||||
echo "✅ Runner配置已生成"
|
||||
echo " 配置檔案:/Users/accusys/markbase/.runner"
|
||||
RUNNER_ID=$(cat /Users/accusys/markbase/.runner | jq '.id')
|
||||
RUNNER_NAME=$(cat /Users/accusys/markbase/.runner | jq -r '.name')
|
||||
echo " Runner ID:$RUNNER_ID"
|
||||
echo " Runner名稱:$RUNNER_NAME"
|
||||
else
|
||||
echo "❌ Runner配置未生成"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#檢查Gitea連接
|
||||
echo "3. 檢查Gitea連接..."
|
||||
RESPONSE=$(curl -sI https://gitea.momentry.ddns.net | head -1)
|
||||
if echo "$RESPONSE" | grep -q "200"; then
|
||||
echo "✅ Gitea連接正常"
|
||||
echo " 地址:https://gitea.momentry.ddns.net"
|
||||
else
|
||||
echo "❌ Gitea連接失敗"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#檢查必要環境
|
||||
echo "4. 檢查必要環境..."
|
||||
if command -v rustc &> /dev/null; then
|
||||
echo "✅ Rust已安裝:$(rustc --version)"
|
||||
else
|
||||
echo "❌ Rust未安裝"
|
||||
fi
|
||||
|
||||
if command -v SwitchAudioSource &> /dev/null; then
|
||||
echo "✅ SwitchAudioSource已安裝"
|
||||
else
|
||||
echo "❌ SwitchAudioSource未安裝"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#檢查launchd服務
|
||||
echo "5. 檢查launchd服務..."
|
||||
SERVICE_STATUS=$(launchctl list | grep gitea)
|
||||
if [ ! -z "$SERVICE_STATUS" ]; then
|
||||
echo "✅ Runner服務已啟動"
|
||||
echo " 狀態:$SERVICE_STATUS"
|
||||
else
|
||||
echo "⚠️ Runner服務未啟動(前景執行請使用 start_runner.sh)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
#檢查日誌
|
||||
echo "6. 檢查Runner日誌..."
|
||||
if [ -f /tmp/gitea-runner.log ]; then
|
||||
echo "✅ Runner日誌存在"
|
||||
echo " 日誌檔案:/tmp/gitea-runner.log"
|
||||
echo " 最後10行:"
|
||||
tail -10 /tmp/gitea-runner.log
|
||||
else
|
||||
echo "⚠️ Runner日誌未生成(Runner尚未執行)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===驗證完成 ==="
|
||||
82
src/audio.rs
Normal file
82
src/audio.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
pub fn voice_for_lang(lang: &str) -> String {
|
||||
match lang {
|
||||
"zh_TW" => "Meijia",
|
||||
"zh_CN" => "Ting-Ting",
|
||||
"en_US" => "Samantha",
|
||||
"en_GB" => "Daniel",
|
||||
"ja_JP" => "Kyoko",
|
||||
"ko_KR" => "Yuna",
|
||||
"fr_FR" => "Amelie",
|
||||
"de_DE" => "Anna",
|
||||
_ => "Meijia",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn phrase_for_lang(lang: &str) -> String {
|
||||
match lang {
|
||||
"zh_TW" | "zh_CN" => "語音測試一二三",
|
||||
"en_US" | "en_GB" => "Test one two three",
|
||||
"ja_JP" => "これはテストです",
|
||||
"ko_KR" => "테스트입니다",
|
||||
"fr_FR" => "Ceci est un test",
|
||||
"de_DE" => "Das ist ein Test",
|
||||
_ => "Test",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn audio_devices() -> (Vec<String>, Vec<String>, String, String) {
|
||||
let run = |t: &str| -> Vec<String> {
|
||||
if let Ok(r) = std::process::Command::new("SwitchAudioSource")
|
||||
.args(["-a", "-t", t, "-f", "json"])
|
||||
.output()
|
||||
{
|
||||
String::from_utf8_lossy(&r.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
serde_json::from_str::<serde_json::Value>(l)
|
||||
.ok()
|
||||
.and_then(|v| v["name"].as_str().map(|s| s.to_string()))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let current = |t: &str| -> String {
|
||||
std::process::Command::new("SwitchAudioSource")
|
||||
.args(["-c", "-t", t])
|
||||
.output()
|
||||
.map(|r| String::from_utf8_lossy(&r.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let out = run("output");
|
||||
let inp = run("input");
|
||||
let co = current("output");
|
||||
let ci = current("input");
|
||||
(out, inp, co, ci)
|
||||
}
|
||||
|
||||
pub fn inject_audio_devices(
|
||||
html: &str,
|
||||
out: &[String],
|
||||
inp: &[String],
|
||||
cur_out: &str,
|
||||
cur_in: &str,
|
||||
) -> String {
|
||||
let mut out_opts = String::from("<option value=\"\">🔊 System</option>");
|
||||
for d in out {
|
||||
let sel = if d == cur_out { " selected" } else { "" };
|
||||
out_opts.push_str(&format!("<option value=\"{d}\"{sel}>{d}</option>"));
|
||||
}
|
||||
let mut inp_opts = String::from("<option value=\"\">🎤 System</option>");
|
||||
for d in inp {
|
||||
let sel = if d == cur_in { " selected" } else { "" };
|
||||
inp_opts.push_str(&format!("<option value=\"{d}\"{sel}>{d}</option>"));
|
||||
}
|
||||
html.replace("{out_devs}", &out_opts)
|
||||
.replace("{in_devs}", &inp_opts)
|
||||
}
|
||||
61
src/command.rs
Normal file
61
src/command.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use axum::response::Json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::audio;
|
||||
|
||||
static CMD_QUEUE: Mutex<Vec<(String, Option<String>)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub async fn post_command(
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let cmd = body["cmd"].as_str().unwrap_or("").to_string();
|
||||
let val = body["val"].as_str().map(|s| s.to_string());
|
||||
let out = body["out"].as_str().map(|s| s.to_string());
|
||||
|
||||
if cmd == "test_voice" {
|
||||
let lang = val.as_deref().unwrap_or("zh_TW");
|
||||
let voice_name = audio::voice_for_lang(lang);
|
||||
let phrase = audio::phrase_for_lang(lang);
|
||||
if let Some(d) = out.as_deref() {
|
||||
if !d.is_empty() {
|
||||
std::process::Command::new("SwitchAudioSource")
|
||||
.args(["-t", "output", "-s", d])
|
||||
.output()
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
std::process::Command::new("say")
|
||||
.args(["-v", &voice_name, &phrase])
|
||||
.spawn()
|
||||
.ok();
|
||||
} else if cmd == "vol_up" {
|
||||
std::process::Command::new("osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"set volume output volume (output volume of (get volume settings)) + 10",
|
||||
])
|
||||
.output()
|
||||
.ok();
|
||||
} else if cmd == "vol_down" {
|
||||
std::process::Command::new("osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"set volume output volume (output volume of (get volume settings)) - 10",
|
||||
])
|
||||
.output()
|
||||
.ok();
|
||||
} else {
|
||||
CMD_QUEUE.lock().unwrap().push((cmd, val));
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"ok": true}))
|
||||
}
|
||||
|
||||
pub async fn get_commands() -> Json<serde_json::Value> {
|
||||
let mut queue = CMD_QUEUE.lock().unwrap();
|
||||
let cmds: Vec<_> = queue
|
||||
.drain(..)
|
||||
.map(|(c, v)| serde_json::json!({"cmd": c, "val": v}))
|
||||
.collect();
|
||||
Json(serde_json::json!(cmds))
|
||||
}
|
||||
243
src/filetree/convert.rs
Normal file
243
src/filetree/convert.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const CACHE_DIR: &str = "data/cache";
|
||||
|
||||
// Phase 1: built-in macOS tools (no installation)
|
||||
const TEXTUTIL_FORMATS: &[&str] = &["docx", "doc", "rtf"];
|
||||
const APPLE_FORMATS: &[&str] = &["pages", "key", "numbers"];
|
||||
|
||||
// Phase 2: soffice/qlmanage fallback
|
||||
const SOFFICE_FORMATS: &[&str] = &["pptx", "ppt", "xlsx", "xls", "odt", "epub"];
|
||||
|
||||
pub fn is_document_ext(ext: &str) -> bool {
|
||||
TEXTUTIL_FORMATS.contains(&ext)
|
||||
|| APPLE_FORMATS.contains(&ext)
|
||||
|| SOFFICE_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn is_textutil_ext(ext: &str) -> bool {
|
||||
TEXTUTIL_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn is_apple_format_ext(ext: &str) -> bool {
|
||||
APPLE_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn get_cached_preview(file_uuid: &str, ext: &str) -> Option<(PathBuf, &'static str)> {
|
||||
if TEXTUTIL_FORMATS.contains(&ext) {
|
||||
get_cached_txt(file_uuid).map(|p| (p, "text/plain; charset=utf-8"))
|
||||
} else if APPLE_FORMATS.contains(&ext) {
|
||||
get_cached_jpg(file_uuid).map(|p| (p, "image/jpeg"))
|
||||
} else {
|
||||
get_cached_pdf(file_uuid).map(|p| (p, "application/pdf"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cached_txt(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.txt", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_jpg(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.jpg", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_pdf(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.pdf", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_png(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.png", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn convert_document(input_path: &Path, file_uuid: &str) -> Result<(PathBuf, &'static str)> {
|
||||
let ext = input_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
// Phase 1: built-in tools (fast, no installation)
|
||||
if TEXTUTIL_FORMATS.contains(&ext.as_str()) {
|
||||
let p = textutil_to_txt(input_path, file_uuid)?;
|
||||
return Ok((p, "text/plain; charset=utf-8"));
|
||||
}
|
||||
|
||||
if APPLE_FORMATS.contains(&ext.as_str()) {
|
||||
match unzip_preview_jpg(input_path, file_uuid) {
|
||||
Ok(p) => return Ok((p, "image/jpeg")),
|
||||
Err(e) => eprintln!("[markbase] unzip preview failed for {}: {}", file_uuid, e),
|
||||
}
|
||||
// Fall back to qlmanage PNG
|
||||
let p = qlmanage_to_png(input_path, file_uuid, 2048)?;
|
||||
return Ok((p, "image/png"));
|
||||
}
|
||||
|
||||
// Phase 2: soffice for Office formats
|
||||
if SOFFICE_FORMATS.contains(&ext.as_str()) {
|
||||
match soffice_to_pdf(input_path, file_uuid) {
|
||||
Ok(p) => return Ok((p, "application/pdf")),
|
||||
Err(e) => {
|
||||
eprintln!("[markbase] soffice failed for {}: {}", file_uuid, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: qlmanage PNG
|
||||
let p = qlmanage_to_png(input_path, file_uuid, 2048)?;
|
||||
Ok((p, "image/png"))
|
||||
}
|
||||
|
||||
// ─── Phase 1: textutil (macOS built-in, .docx/.doc/.rtf → .txt) ───
|
||||
|
||||
fn textutil_to_txt(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.txt", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("textutil")
|
||||
.args(["-convert", "txt", "-output"])
|
||||
.arg(&output)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run textutil")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("textutil: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("textutil did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 1: unzip preview.jpg from iWork packages ───
|
||||
|
||||
fn unzip_preview_jpg(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.jpg", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let tmp = cache_dir.join(format!("_tmp_{}", file_uuid));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
|
||||
let out = Command::new("unzip")
|
||||
.args(["-o", "-d"])
|
||||
.arg(&tmp)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to unzip iWork package")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("unzip: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
// Look for preview.jpg, preview.pdf, or quicklook/thumbnail.jpg
|
||||
for name in &[
|
||||
"preview.jpg",
|
||||
"preview.png",
|
||||
"preview.pdf",
|
||||
"preview-web.jpg",
|
||||
] {
|
||||
let src = tmp.join(name);
|
||||
if src.exists() {
|
||||
std::fs::copy(&src, &output)?;
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
return Ok(output);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
anyhow::bail!("no preview found in iWork package")
|
||||
}
|
||||
|
||||
// ─── Phase 2: soffice (LibreOffice, multi-page PDF) ───
|
||||
|
||||
fn soffice_to_pdf(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.pdf", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("soffice")
|
||||
.args(["--headless", "--convert-to", "pdf", "--outdir"])
|
||||
.arg(cache_dir)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run soffice")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("soffice: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
let basename = input_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let generated = cache_dir.join(format!("{}.pdf", basename));
|
||||
if generated.exists() && generated != output {
|
||||
std::fs::rename(&generated, &output)?;
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("soffice did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 2: qlmanage (Apple QuickLook, PNG thumbnail) ───
|
||||
|
||||
fn qlmanage_to_png(input_path: &Path, file_uuid: &str, size: u32) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.png", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("qlmanage")
|
||||
.args(["-t", "-s", &size.to_string(), "-o"])
|
||||
.arg(cache_dir)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run qlmanage")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("qlmanage: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
let filename = input_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let generated = cache_dir.join(format!("{}.png", filename));
|
||||
if generated.exists() && generated != output {
|
||||
std::fs::rename(&generated, &output)?;
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("qlmanage did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
550
src/filetree/mod.rs
Normal file
550
src/filetree/mod.rs
Normal file
@@ -0,0 +1,550 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::filetree::node::{Aliases, FileNode, NodeType};
|
||||
|
||||
pub mod convert;
|
||||
pub mod mode;
|
||||
pub mod modes;
|
||||
pub mod node;
|
||||
|
||||
pub struct FileTree {
|
||||
pub user_id: String,
|
||||
pub nodes: Vec<FileNode>,
|
||||
}
|
||||
|
||||
const CREATE_TABLES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS file_registry (
|
||||
file_uuid TEXT PRIMARY KEY,
|
||||
original_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
file_type TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_nodes (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL DEFAULT '{}',
|
||||
file_uuid TEXT,
|
||||
sha256 TEXT,
|
||||
parent_id TEXT,
|
||||
children_json TEXT NOT NULL DEFAULT '[]',
|
||||
node_type TEXT NOT NULL DEFAULT 'folder',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
bg_color TEXT,
|
||||
file_size INTEGER,
|
||||
registered_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_uuid TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
label TEXT,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(file_uuid, location)
|
||||
);
|
||||
";
|
||||
|
||||
impl FileTree {
|
||||
pub fn user_db_path(user_id: &str) -> String {
|
||||
format!("data/users/{}.sqlite", user_id)
|
||||
}
|
||||
|
||||
pub fn init_user_db(user_id: &str) -> Result<Connection> {
|
||||
let db_path = Self::user_db_path(user_id);
|
||||
let parent = std::path::Path::new(&db_path).parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
conn.execute_batch(CREATE_TABLES)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn open_user_db(user_id: &str) -> Result<Connection> {
|
||||
let db_path = Self::user_db_path(user_id);
|
||||
Connection::open(&db_path).with_context(|| format!("Failed to open {}", db_path))
|
||||
}
|
||||
|
||||
pub fn load(conn: &Connection, user_id: &str) -> Result<Self> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json,
|
||||
node_type, icon, color, bg_color, file_size, registered_at,
|
||||
created_at, updated_at, sort_order
|
||||
FROM file_nodes ORDER BY sort_order ASC, created_at ASC",
|
||||
)?;
|
||||
|
||||
let nodes: Vec<FileNode> = stmt
|
||||
.query_map([], |row| {
|
||||
let children_json: String = row.get(6)?;
|
||||
let children: Vec<String> =
|
||||
serde_json::from_str(&children_json).unwrap_or_default();
|
||||
Ok(FileNode {
|
||||
node_id: row.get(0)?,
|
||||
label: row.get(1)?,
|
||||
aliases: Aliases::from_json(&row.get::<_, String>(2)?),
|
||||
file_uuid: row.get(3)?,
|
||||
sha256: row.get(4)?,
|
||||
parent_id: row.get(5)?,
|
||||
children,
|
||||
node_type: NodeType::from_str(&row.get::<_, String>(7)?),
|
||||
icon: row.get(8)?,
|
||||
color: row.get(9)?,
|
||||
bg_color: row.get(10)?,
|
||||
file_size: row.get(11)?,
|
||||
registered_at: row.get(12)?,
|
||||
created_at: row.get(13)?,
|
||||
updated_at: row.get(14)?,
|
||||
sort_order: row.get(15)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(FileTree {
|
||||
user_id: user_id.to_string(),
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_node(&mut self, conn: &Connection, node: &FileNode) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO file_nodes (node_id, label, aliases_json, file_uuid, sha256, parent_id,
|
||||
children_json, node_type, icon, color, bg_color, file_size, registered_at,
|
||||
created_at, updated_at, sort_order)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
rusqlite::params![
|
||||
node.node_id,
|
||||
node.label,
|
||||
node.aliases.to_json(),
|
||||
node.file_uuid,
|
||||
node.sha256,
|
||||
node.parent_id,
|
||||
serde_json::to_string(&node.children).unwrap_or_else(|_| "[]".to_string()),
|
||||
node.node_type.as_str(),
|
||||
node.icon,
|
||||
node.color,
|
||||
node.bg_color,
|
||||
node.file_size,
|
||||
node.registered_at,
|
||||
node.created_at,
|
||||
node.updated_at,
|
||||
node.sort_order,
|
||||
],
|
||||
)?;
|
||||
self.nodes.push(node.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_node(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
updated: &FileNode,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET label=?1, aliases_json=?2, file_uuid=?3, sha256=?4, parent_id=?5,
|
||||
children_json=?6, node_type=?7, icon=?8, color=?9, bg_color=?10,
|
||||
file_size=?11, registered_at=?12,
|
||||
updated_at=?13, sort_order=?14
|
||||
WHERE node_id=?15",
|
||||
rusqlite::params![
|
||||
updated.label,
|
||||
updated.aliases.to_json(),
|
||||
updated.file_uuid,
|
||||
updated.sha256,
|
||||
updated.parent_id,
|
||||
serde_json::to_string(&updated.children)
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
updated.node_type.as_str(),
|
||||
updated.icon,
|
||||
updated.color,
|
||||
updated.bg_color,
|
||||
updated.file_size,
|
||||
updated.registered_at,
|
||||
updated.updated_at,
|
||||
updated.sort_order,
|
||||
node_id,
|
||||
],
|
||||
)?;
|
||||
|
||||
if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) {
|
||||
*n = updated.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_node_alias(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
lang: &str,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
let node = self
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.node_id == node_id)
|
||||
.context("Node not found")?;
|
||||
|
||||
node.aliases.set(lang, value);
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
node.updated_at = now.clone();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET aliases_json=?1, updated_at=?2 WHERE node_id=?3",
|
||||
rusqlite::params![node.aliases.to_json(), now, node_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_node(&mut self, conn: &Connection, node_id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM file_nodes WHERE node_id=?1", [node_id])?;
|
||||
self.nodes.retain(|n| n.node_id != node_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn move_node(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
new_parent_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET parent_id=?1, updated_at=?2 WHERE node_id=?3",
|
||||
rusqlite::params![new_parent_id, now, node_id],
|
||||
)?;
|
||||
|
||||
if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) {
|
||||
n.parent_id = new_parent_id;
|
||||
n.updated_at = now;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_tree(&self) -> Vec<FileNode> {
|
||||
let mut roots: Vec<FileNode> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.parent_id.is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for root in &mut roots {
|
||||
self.fill_children(root);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn fill_children(&self, node: &mut FileNode) {
|
||||
let child_ids: Vec<String> = node.children.clone();
|
||||
let mut children: Vec<FileNode> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
n.parent_id.as_deref() == Some(&node.node_id) && child_ids.contains(&n.node_id)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for child in &mut children {
|
||||
self.fill_children(child);
|
||||
}
|
||||
|
||||
node.children = children.iter().map(|c| c.node_id.clone()).collect();
|
||||
node.children
|
||||
.extend(children.iter().flat_map(|c| c.children.clone()));
|
||||
}
|
||||
|
||||
pub fn new_folder(label: &str, parent_id: Option<String>) -> FileNode {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
FileNode {
|
||||
node_id: Uuid::new_v4().to_string(),
|
||||
label: label.to_string(),
|
||||
aliases: Aliases::empty(),
|
||||
file_uuid: None,
|
||||
sha256: None,
|
||||
parent_id,
|
||||
children: vec![],
|
||||
node_type: NodeType::Folder,
|
||||
icon: None,
|
||||
color: None,
|
||||
bg_color: None,
|
||||
file_size: None,
|
||||
registered_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_file_node(
|
||||
label: &str,
|
||||
file_uuid: &str,
|
||||
sha256: Option<&str>,
|
||||
original_name: &str,
|
||||
file_size: Option<i64>,
|
||||
file_type: Option<&str>,
|
||||
registered_at: Option<&str>,
|
||||
parent_id: Option<String>,
|
||||
) -> (FileNode, Option<String>) {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let reg_at = registered_at
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| now.clone());
|
||||
let node = FileNode {
|
||||
node_id: Uuid::new_v4().to_string(),
|
||||
label: label.to_string(),
|
||||
aliases: Aliases::empty(),
|
||||
file_uuid: Some(file_uuid.to_string()),
|
||||
sha256: sha256.map(|s| s.to_string()),
|
||||
parent_id,
|
||||
children: vec![],
|
||||
node_type: NodeType::File,
|
||||
icon: None,
|
||||
color: None,
|
||||
bg_color: None,
|
||||
file_size,
|
||||
registered_at: Some(reg_at),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
let register_sql =
|
||||
format!(
|
||||
"INSERT OR REPLACE INTO file_registry (file_uuid, original_name, file_size, file_type,
|
||||
registered_at, status) VALUES ('{}', '{}', {}, {}, '{}', 'active')",
|
||||
file_uuid,
|
||||
original_name.replace('\'', "''"),
|
||||
file_size.map_or("NULL".to_string(), |s| s.to_string()),
|
||||
file_type.map_or("NULL".to_string(), |t| format!("'{}'", t.replace('\'', "''"))),
|
||||
now,
|
||||
);
|
||||
|
||||
(node, Some(register_sql))
|
||||
}
|
||||
|
||||
pub fn add_location(
|
||||
conn: &Connection,
|
||||
file_uuid: &str,
|
||||
location: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO file_locations (file_uuid, location, label) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![file_uuid, location, label],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_file_info(conn: &Connection, file_uuid: &str) -> Result<serde_json::Value> {
|
||||
let mut virtual_paths: Vec<String> = Vec::new();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT fn.node_id, fn.label, fn.parent_id FROM file_nodes fn WHERE fn.file_uuid = ?1",
|
||||
)?;
|
||||
let node_rows: Vec<(String, String, Option<String>)> = stmt
|
||||
.query_map([file_uuid], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (_nid, _label, _parent_id) in &node_rows {
|
||||
let mut path_parts: Vec<String> = vec![];
|
||||
let mut current = _parent_id.clone();
|
||||
while let Some(pid) = current {
|
||||
let folder: Option<(String, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT label, parent_id FROM file_nodes WHERE node_id = ?1",
|
||||
[&pid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
if let Some((name, next_pid)) = folder {
|
||||
path_parts.push(name);
|
||||
current = next_pid;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
path_parts.reverse();
|
||||
virtual_paths.push(path_parts.join(" / "));
|
||||
}
|
||||
|
||||
let mut real_locations: Vec<serde_json::Value> = Vec::new();
|
||||
let mut lstmt = conn.prepare(
|
||||
"SELECT location, label, added_at FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at",
|
||||
)?;
|
||||
let locs: Vec<(String, Option<String>, String)> = lstmt
|
||||
.query_map([file_uuid], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for (loc, lbl, added) in &locs {
|
||||
real_locations.push(serde_json::json!({
|
||||
"path": loc,
|
||||
"label": lbl,
|
||||
"added_at": added,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"file_uuid": file_uuid,
|
||||
"virtual_paths": virtual_paths,
|
||||
"real_locations": real_locations,
|
||||
"node_count": node_rows.len(),
|
||||
"location_count": locs.len(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db() -> (Connection, String) {
|
||||
let user_id = format!("test_{}", Uuid::new_v4());
|
||||
let conn = FileTree::init_user_db(&user_id).unwrap();
|
||||
(conn, user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_and_load_empty_tree() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(tree.user_id, user_id);
|
||||
assert!(tree.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_load_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 1);
|
||||
assert_eq!(loaded.nodes[0].label, "Videos");
|
||||
assert_eq!(loaded.nodes[0].node_type, NodeType::Folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let mut folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
folder.label = "Movies".to_string();
|
||||
folder.icon = Some("📽️".to_string());
|
||||
folder.color = Some("#ff0000".to_string());
|
||||
tree.update_node(&conn, &folder.node_id, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes[0].label, "Movies");
|
||||
assert_eq!(loaded.nodes[0].icon, Some("📽️".to_string()));
|
||||
assert_eq!(loaded.nodes[0].color, Some("#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Temp", None);
|
||||
let node_id = folder.node_id.clone();
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.delete_node(&conn, &node_id).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child = FileTree::new_folder("Child", Some(root.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child).unwrap();
|
||||
|
||||
tree.move_node(&conn, &child.node_id, None).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let moved = loaded.nodes.iter().find(|n| n.label == "Child").unwrap();
|
||||
assert!(moved.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_alias() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.update_node_alias(&conn, &folder.node_id, "zh_tw", "影片")
|
||||
.unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(
|
||||
loaded.nodes[0].aliases.get("zh_tw").map(|s| s.as_str()),
|
||||
Some("影片")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tree() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone()));
|
||||
let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone()));
|
||||
let grandchild = FileTree::new_folder("Grandchild", Some(child1.node_id.clone()));
|
||||
|
||||
// Set parent-child relationships
|
||||
let mut root_with_children = root.clone();
|
||||
root_with_children.children = vec![child1.node_id.clone(), child2.node_id.clone()];
|
||||
let mut child1_with_children = child1.clone();
|
||||
child1_with_children.children = vec![grandchild.node_id.clone()];
|
||||
|
||||
tree.insert_node(&conn, &root_with_children).unwrap();
|
||||
tree.insert_node(&conn, &child1_with_children).unwrap();
|
||||
tree.insert_node(&conn, &child2).unwrap();
|
||||
tree.insert_node(&conn, &grandchild).unwrap();
|
||||
|
||||
let roots = tree.build_tree();
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].label, "Root");
|
||||
}
|
||||
}
|
||||
43
src/filetree/mode.rs
Normal file
43
src/filetree/mode.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SortOption {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FilterOption {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DisplayMode: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn render(&self, tree: &FileTree) -> Value;
|
||||
fn sort_options(&self) -> Vec<SortOption>;
|
||||
fn filter_options(&self) -> Vec<FilterOption>;
|
||||
}
|
||||
|
||||
pub fn get_mode(name: &str) -> Option<Box<dyn DisplayMode>> {
|
||||
match name {
|
||||
"tree" => Some(Box::new(crate::filetree::modes::tree::TreeMode)),
|
||||
"list" => Some(Box::new(crate::filetree::modes::list::ListMode)),
|
||||
"grid_sm" => Some(Box::new(crate::filetree::modes::grid_sm::GridSmMode)),
|
||||
"grid_lg" => Some(Box::new(crate::filetree::modes::grid_lg::GridLgMode)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_modes() -> Vec<Box<dyn DisplayMode>> {
|
||||
vec![
|
||||
Box::new(crate::filetree::modes::list::ListMode),
|
||||
Box::new(crate::filetree::modes::tree::TreeMode),
|
||||
Box::new(crate::filetree::modes::grid_sm::GridSmMode),
|
||||
Box::new(crate::filetree::modes::grid_lg::GridLgMode),
|
||||
]
|
||||
}
|
||||
83
src/filetree/modes/grid_lg.rs
Normal file
83
src/filetree/modes/grid_lg.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct GridLgMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for GridLgMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"grid_lg"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"sort_order": n.sort_order,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "grid_lg",
|
||||
"user_id": tree.user_id,
|
||||
"cell_size": 192,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "type".into(),
|
||||
label: "By Type".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "video".into(),
|
||||
label: "Video Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
72
src/filetree/modes/grid_sm.rs
Normal file
72
src/filetree/modes/grid_sm.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct GridSmMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for GridSmMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"grid_sm"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "grid_sm",
|
||||
"user_id": tree.user_id,
|
||||
"cell_size": 96,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
87
src/filetree/modes/list.rs
Normal file
87
src/filetree/modes/list.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct ListMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for ListMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"list"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"sha256": n.sha256,
|
||||
"parent_id": n.parent_id,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"file_size": n.file_size,
|
||||
"registered_at": n.registered_at,
|
||||
"sort_order": n.sort_order,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "list",
|
||||
"user_id": tree.user_id,
|
||||
"columns": ["icon", "label", "node_type", "updated_at"],
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_asc".into(),
|
||||
label: "Oldest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "type".into(),
|
||||
label: "By Type".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
4
src/filetree/modes/mod.rs
Normal file
4
src/filetree/modes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod grid_lg;
|
||||
pub mod grid_sm;
|
||||
pub mod list;
|
||||
pub mod tree;
|
||||
82
src/filetree/modes/tree.rs
Normal file
82
src/filetree/modes/tree.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct TreeMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for TreeMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"tree"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"sha256": n.sha256,
|
||||
"parent_id": n.parent_id,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"file_size": n.file_size,
|
||||
"registered_at": n.registered_at,
|
||||
"children": n.children,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "tree",
|
||||
"user_id": tree.user_id,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_asc".into(),
|
||||
label: "Oldest First".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
84
src/filetree/node.rs
Normal file
84
src/filetree/node.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileNode {
|
||||
pub node_id: String,
|
||||
pub label: String,
|
||||
pub aliases: Aliases,
|
||||
pub file_uuid: Option<String>,
|
||||
pub sha256: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub children: Vec<String>,
|
||||
pub node_type: NodeType,
|
||||
pub icon: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub bg_color: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub registered_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Aliases {
|
||||
#[serde(flatten)]
|
||||
pub map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Aliases {
|
||||
pub fn empty() -> Self {
|
||||
Aliases {
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string(&self.map).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
pub fn from_json(s: &str) -> Self {
|
||||
let map: HashMap<String, String> = serde_json::from_str(s).unwrap_or_default();
|
||||
Aliases { map }
|
||||
}
|
||||
|
||||
pub fn set(&mut self, lang: &str, value: &str) {
|
||||
self.map.insert(lang.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
pub fn get(&self, lang: &str) -> Option<&String> {
|
||||
self.map.get(lang)
|
||||
}
|
||||
|
||||
pub fn first_value(&self) -> Option<&String> {
|
||||
self.map.values().next()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeType {
|
||||
Folder,
|
||||
File,
|
||||
DynamicLayer,
|
||||
}
|
||||
|
||||
impl NodeType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NodeType::Folder => "folder",
|
||||
NodeType::File => "file",
|
||||
NodeType::DynamicLayer => "dynamic_layer",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"folder" => NodeType::Folder,
|
||||
"file" => NodeType::File,
|
||||
"dynamic_layer" => NodeType::DynamicLayer,
|
||||
_ => NodeType::Folder,
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod audio;
|
||||
pub mod command;
|
||||
pub mod filetree;
|
||||
pub mod render;
|
||||
pub mod server;
|
||||
46
src/main.rs
Normal file
46
src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "markbase", about = "Momentry Display Engine")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Start display server
|
||||
Display {
|
||||
#[arg(short, long, default_value = "11438")]
|
||||
port: u16,
|
||||
/// Optional initial markdown file
|
||||
file: Option<String>,
|
||||
},
|
||||
/// Render markdown to HTML (stdout)
|
||||
Render {
|
||||
file: String,
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Display { port, file } => {
|
||||
markbase::server::run(port, file).await?;
|
||||
}
|
||||
Commands::Render { file, output } => {
|
||||
let md = std::fs::read_to_string(&file)?;
|
||||
let html = markbase::render::md_to_html(&md);
|
||||
if let Some(path) = &output {
|
||||
std::fs::write(path, html)?;
|
||||
} else {
|
||||
println!("{html}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
633
src/page.html
Normal file
633
src/page.html
Normal file
@@ -0,0 +1,633 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Cache-Control" content="no-cache,no-store,must-revalidate">
|
||||
<title>{__TITLE__}</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,system-ui,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px 24px 80px;line-height:1.6;font-size:16px}
|
||||
h1,h2,h3,h4{color:#60a5fa;margin:1em 0 .5em}
|
||||
h1{font-size:1.8em;border-bottom:1px solid #334155;padding-bottom:.3em}
|
||||
h2{font-size:1.4em} h3{font-size:1.15em}
|
||||
p{margin:.6em 0} a{color:#60a5fa}
|
||||
code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:.9em}
|
||||
pre{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto;margin:.8em 0;font-size:.85em}
|
||||
pre code{background:none;padding:0}
|
||||
table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.88em}
|
||||
th,td{border:1px solid #334155;padding:8px 12px;text-align:left;vertical-align:top}
|
||||
th{background:#1e293b;color:#94a3b8;font-weight:600;white-space:nowrap}
|
||||
blockquote{border-left:4px solid #60a5fa;padding-left:16px;color:#94a3b8;margin:.8em 0}
|
||||
ul,ol{padding-left:24px;margin:.6em 0}
|
||||
img,video{max-width:100%;border-radius:8px} iframe{width:100%;height:98vh;border:none}
|
||||
|
||||
#mb-tree-panel{display:none;position:fixed;top:0;left:0;right:0;bottom:52px;background:#0f172a;z-index:9998;overflow-y:auto;padding:16px 24px}
|
||||
#mb-tree-panel.active{display:block}
|
||||
.mb-mode-bar{display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid #1e293b;position:sticky;top:0;background:#0f172a;z-index:10}
|
||||
.mb-mode-btn{background:none;border:none;color:#64748b;padding:10px 20px;cursor:pointer;font-size:14px;border-bottom:3px solid transparent;transition:all .2s;font-family:inherit}
|
||||
.mb-mode-btn:hover{color:#94a3b8}
|
||||
.mb-mode-btn.active{color:#60a5fa;border-bottom-color:#60a5fa}
|
||||
.mb-mode-btn span{font-size:18px;margin-right:6px}
|
||||
.mb-tree-node{padding:3px 0;cursor:default;border-radius:4px}
|
||||
.mb-tree-node:hover{background:#1e293b}
|
||||
.mb-tree-caret{display:inline-block;width:18px;cursor:pointer;color:#64748b;user-select:none}
|
||||
.mb-tree-label{display:inline-flex;align-items:center;gap:6px}
|
||||
.mb-tree-meta{color:#64748b;font-size:11px;margin-left:8px}
|
||||
.mb-tree-file{cursor:pointer}
|
||||
.mb-tree-file:hover{color:#60a5fa}
|
||||
.mb-folder-actions{display:none;gap:3px;margin-left:8px}
|
||||
.mb-tree-node:hover .mb-folder-actions{display:inline-flex}
|
||||
body.mb-locked .mb-folder-actions{display:none!important}
|
||||
body.mb-locked .mb-tree-node:hover .mb-folder-actions{display:none!important}
|
||||
.mb-folder-btn{background:#334155;border:none;color:#94a3b8;padding:1px 6px;border-radius:3px;cursor:pointer;font-size:10px;font-family:inherit}
|
||||
.mb-folder-btn:hover{background:#475569;color:#e2e8f0}
|
||||
.mb-folder-btn.danger:hover{background:#7f1d1d;color:#fca5a5}
|
||||
.mb-toast{position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:#064e3b;color:#4ade80;padding:6px 20px;border-radius:6px;font-size:13px;z-index:10001;transition:opacity .3s}
|
||||
|
||||
.mb-grid{display:grid;gap:10px}
|
||||
.mb-grid.sm{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
|
||||
.mb-grid.lg{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
|
||||
.mb-grid-cell{background:#1e293b;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:all .2s;border:1px solid transparent;min-height:100px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px}
|
||||
.mb-grid-cell:hover{background:#1e3a5f;border-color:#3b82f6;transform:translateY(-2px)}
|
||||
.mb-grid-cell .mb-grid-icon{font-size:40px}
|
||||
.mb-grid-cell .mb-grid-label{font-size:12px;line-height:1.3;max-height:2.6em;overflow:hidden;word-break:break-word}
|
||||
.mb-grid-cell .mb-grid-uuid{font-size:10px;color:#64748b;font-family:monospace}
|
||||
|
||||
#mb-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:9999}
|
||||
#mb-overlay.active{display:block}
|
||||
#mb-detail{display:none;position:fixed;top:10%;left:10%;right:10%;bottom:10%;background:#1e293b;border:1px solid #334155;border-radius:12px;z-index:10000;padding:24px;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
||||
#mb-detail.active{display:block}
|
||||
#mb-detail-close{position:absolute;top:12px;right:16px;background:none;border:none;color:#64748b;font-size:20px;cursor:pointer}
|
||||
.mb-loc-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;margin:2px 4px 2px 0;font-family:monospace}
|
||||
.mb-loc-tag.hot{background:#064e3b;color:#4ade80}
|
||||
.mb-loc-tag.warm{background:#451a03;color:#fbbf24}
|
||||
.mb-loc-tag.cold{background:#1e1b4b;color:#818cf8}
|
||||
.mb-loc-tag.cloud{background:#0c4a6e;color:#38bdf8}
|
||||
.mb-new-folder-btn{background:#1e3a5f;border:1px solid #3b82f6;color:#60a5fa;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit;margin-left:8px}
|
||||
.mb-new-folder-btn:hover{background:#1e40af;color:#93c5fd}
|
||||
.mb-lock-btn{background:none;border:none;font-size:16px;cursor:pointer;padding:2px 6px;align-self:center}
|
||||
.mb-rename-input{background:#1e293b;border:1px solid #60a5fa;border-radius:4px;color:#e2e8f0;padding:1px 6px;font-size:13px;font-family:inherit;width:180px}
|
||||
.mb-icon-picker{display:grid;grid-template-columns:repeat(8,1fr);gap:4px;max-height:200px;overflow-y:auto;margin:8px 0}
|
||||
.mb-icon-picker button{background:#0f172a;border:2px solid transparent;border-radius:6px;font-size:22px;padding:6px;cursor:pointer}
|
||||
.mb-icon-picker button:hover{background:#1e3a5f;border-color:#60a5fa}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||
<script>mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{primaryColor:'#60a5fa',primaryTextColor:'#e2e8f0',lineColor:'#94a3b8'}})</script>
|
||||
</head><body>
|
||||
<div id=mb-content>{__CONTENT__}</div>
|
||||
<div id=mb-overlay onclick="closeDetail()"></div>
|
||||
<div id=mb-detail><button id=mb-detail-close onclick="closeDetail()">✕</button><div id=mb-detail-body></div></div>
|
||||
<div id=mb-tree-panel><div id=mb-tree-body></div></div>
|
||||
|
||||
<div id=mb-bar style="position:fixed;bottom:0;left:0;right:0;background:#1e293b;border-top:1px solid #334155;display:flex;justify-content:center;align-items:center;gap:5px;padding:5px 10px;z-index:9999;font-size:12px">
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'restart'})})" title="Restart">⏮</button>
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'prev'})})" title="Prev">◀</button>
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'next'})})" title="Next">▶</button>
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'end'})})" title="End">⏭</button>
|
||||
<input id=mbgi type=number min=1 max=999 placeholder=N style="width:36px;background:#0f172a;border:1px solid #334155;border-radius:4px;color:white;padding:2px 4px;font-size:10px;text-align:center">
|
||||
<button onclick="var n=document.getElementById('mbgi').value;if(n)fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'goto',val:parseInt(n)})})" style=font-size:10px>GO</button>
|
||||
<select id=mbstep onchange="if(this.value)fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'goto',val:parseInt(this.value)})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:160px"><option value="">Step...</option></select>
|
||||
<span style=color:#475569;font-size:10px>|</span>
|
||||
<button onclick="toggleTree()" title="File Tree" style="background:none;border:none;color:#60a5fa;cursor:pointer;font-size:16px">🗂</button>
|
||||
<span style=color:#475569;font-size:10px>|</span>
|
||||
<button onclick="var t=this.textContent;this.textContent=t===String.fromCodePoint(0x1F50A)?String.fromCodePoint(0x1F507):String.fromCodePoint(0x1F50A)" id=mbvb title=Voice style=font-size:16px>🔊</button>
|
||||
<select id=mbvl onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'lang',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px">
|
||||
<option value=zh_TW>🇹🇼</option><option value=en_US>🇺🇸</option><option value=ja_JP>🇯🇵</option><option value=ko_KR>🇰🇷</option><option value=fr_FR>🇫🇷</option></select>
|
||||
<select id=mbol onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'audio_out',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:120px">{out_devs}</select>
|
||||
<button onclick="var l=document.getElementById('mbvl').value;var o=document.getElementById('mbol').value;fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'test_voice',val:l,out:o})})" style=font-size:14px title="Test Voice">🔊</button>
|
||||
<select id=mbil onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'audio_in',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:120px">{in_devs}</select>
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'vol_down'})})" style=font-size:13px title="Vol-">➖</button>
|
||||
<span id=mbvlvl style=color:#4ade80;font-size:11px>--</span>
|
||||
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'vol_up'})})" style=font-size:13px title="Vol+">➕</button>
|
||||
<span id=mbsi style=color:#94a3b8;font-size:10px;margin-left:2px></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Page version polling (skip while tree or detail panel is open)
|
||||
var _v=-1;
|
||||
setInterval(function(){
|
||||
if(_tv)return;
|
||||
var ov=document.getElementById("mb-overlay");
|
||||
if(ov&&ov.style.display==="block")return;
|
||||
fetch("/version").then(function(r){return r.json()}).then(function(d){
|
||||
if(d.v!=_v){_v=d.v;
|
||||
var ov2=document.getElementById("mb-overlay");
|
||||
if(ov2&&ov2.style.display==="block")return;
|
||||
if(_v>=0)fetch("/body").then(function(r){return r.text()}).then(function(h){
|
||||
var ov3=document.getElementById("mb-overlay");
|
||||
if(ov3&&ov3.style.display==="block")return;
|
||||
var e=document.getElementById("mb-content");
|
||||
if(e){e.innerHTML=h;mermaid.run()}})}})
|
||||
},500)
|
||||
|
||||
setInterval(function(){
|
||||
fetch("/status").then(function(r){return r.json()}).then(function(d){
|
||||
var s="";d.id&&(s+="["+d.id+"] ");d.step&&(s+="Step "+d.step+"/"+d.total);d.label&&(s+=" "+d.label);
|
||||
var e=document.getElementById("mbsi");if(e)e.textContent=s})
|
||||
},1000)
|
||||
|
||||
fetch("/volume").then(function(r){return r.json()}).then(function(d){
|
||||
var e=document.getElementById("mbvlvl");if(e){e.textContent=d.level}
|
||||
})
|
||||
fetch("/labels").then(function(r){return r.json()}).then(function(d){
|
||||
var s=document.getElementById("mbstep");
|
||||
d.forEach(function(x){var o=document.createElement("option");o.value=x.num;o.text=x.num+". "+x.label;s.appendChild(o)})
|
||||
})
|
||||
|
||||
// ═══════════════ FILE TREE PANEL ═══════════════
|
||||
var _tv=false, _tm="tree", _td=null;
|
||||
|
||||
function toggleTree(){
|
||||
_tv=!_tv;
|
||||
document.getElementById("mb-tree-panel").classList.toggle("active",_tv);
|
||||
if(_tv)loadTree();
|
||||
}
|
||||
|
||||
function loadTree(){
|
||||
var b=document.getElementById("mb-tree-body");
|
||||
if(!b)return;
|
||||
b.innerHTML="<div style=text-align:center;padding:40px;color:#64748b>Loading...</div>";
|
||||
fetch("/api/v2/tree/demo?mode="+_tm).then(function(r){return r.json()}).then(function(d){
|
||||
_td=d;
|
||||
var h="";
|
||||
// Mode buttons
|
||||
var modes=[{k:"tree",i:"🌳",l:"Tree"},{k:"list",i:"📋",l:"List"},{k:"grid_sm",i:"🟦",l:"Icons"},{k:"grid_lg",i:"🔲",l:"Large"}];
|
||||
h+="<div class=mb-mode-bar>";
|
||||
modes.forEach(function(m){
|
||||
h+="<button class=mb-mode-btn"+(_tm==m.k?" active":"")+" onclick='changeMode(\""+m.k+"\")'>";
|
||||
h+="<span>"+m.i+"</span>"+m.l+"</button>";
|
||||
});
|
||||
h+="<span style=flex:1></span>";
|
||||
h+="<button class=mb-lock-btn id=mb-lock-icon onclick=toggleLock() title='Toggle edit lock'>"+(_locked?"🔒":"🔓")+"</button>";
|
||||
if(!_locked){
|
||||
h+="<button class=mb-new-folder-btn onclick='document.getElementById(\"mb-file-input\").click()' style='background:#064e3b;border-color:#4ade80;color:#4ade80'>📤 Upload</button>";
|
||||
h+="<input type=file id=mb-file-input style=display:none onchange=uploadFile(this)>";
|
||||
}
|
||||
if(!_locked){
|
||||
h+="<button class=mb-new-folder-btn onclick=organizeTree() style='background:#0c4a6e;border-color:#38bdf8;color:#38bdf8'>⚡ Agent</button>";
|
||||
}
|
||||
h+="<button class=mb-new-folder-btn onclick=newFolder()>+ Folder</button>";
|
||||
if(!_locked){
|
||||
h+="<button class=mb-new-folder-btn onclick=restoreTree() style='background:#1e3a5f;border-color:#3b82f6;color:#93c5fd'>↻ Restore</button>";
|
||||
h+="<button class=mb-new-folder-btn onclick=findDupes() style='background:#451a03;color:#fbbf24;border-color:#b45309'>🔍 Dupes</button>";
|
||||
h+="<button class=mb-new-folder-btn onclick=deleteAll() style='background:#451a03;color:#fbbf24;border-color:#b45309'>✕ All</button>";
|
||||
}
|
||||
h+="<span style=color:#64748b;font-size:12px;align-self:center>"+d.nodes.length+" nodes</span></div>";
|
||||
|
||||
if(_tm=="tree")h+=renderTree(d);
|
||||
else if(_tm=="list")h+=renderList(d);
|
||||
else h+=renderGrid(d,_tm);
|
||||
b.innerHTML=h;
|
||||
}).catch(function(e){
|
||||
b.innerHTML="<div style=padding:20px;color:#ef4444>Failed to load tree: "+e+"</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function changeMode(m){
|
||||
_tm=m;localStorage.setItem("display_mode",m);
|
||||
loadTree();
|
||||
}
|
||||
|
||||
function dname(n){
|
||||
var a=n.aliases||{};
|
||||
for(var k in a){if(a.hasOwnProperty(k)&&a[k])return a[k];}
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function fsize(b){
|
||||
if(!b&&b!==0)return "-";
|
||||
var s=b,i=0,u=["B","KB","MB","GB"];
|
||||
while(s>=1024&&i<3){s/=1024;i++;}
|
||||
return (i==0?s:s.toFixed(1))+" "+u[i];
|
||||
}
|
||||
|
||||
// TREE MODE
|
||||
var _clk=0;
|
||||
function tgl(id){
|
||||
var el=document.getElementById(id);
|
||||
if(el){var v=el.style.display=="none";el.style.display=v?"":"none";
|
||||
var c=document.getElementById("cr"+id);if(c)c.textContent=v?"▼":"▶";}
|
||||
}
|
||||
function renderTree(d){
|
||||
var ch={};
|
||||
d.nodes.forEach(function(n){var p=n.parent_id||"root";if(!ch[p])ch[p]=[];ch[p].push(n);});
|
||||
function rc(pid,ind){
|
||||
var lst=ch[pid];if(!lst||!lst.length)return"";
|
||||
var h="";
|
||||
lst.sort(function(a,b){if(a.node_type!=b.node_type)return a.node_type=="folder"?-1:1;return a.label.localeCompare(b.label);});
|
||||
lst.forEach(function(n){
|
||||
if(n.node_type=="folder"){
|
||||
_clk++;var cid="tc"+_clk;var nid=n.node_id;
|
||||
h+="<div class=mb-tree-node style=padding-left:"+(ind*20)+"px>";
|
||||
h+="<span class=mb-tree-caret id=cr"+cid+" onclick='tgl(\""+cid+"\")'>"+(ind==0?"▼":"▶")+"</span>";
|
||||
h+="<span class=mb-tree-label ondblclick='renameNode(\""+nid+"\")'>"+(n.icon||"📁")+" <b id=flb"+nid+">"+dname(n)+"</b></span>";
|
||||
h+="<span class=mb-folder-actions>";
|
||||
h+="<button class=mb-folder-btn onclick='pickIcon(\""+nid+"\")'>🎨</button>";
|
||||
h+="<button class=mb-folder-btn onclick='renameNode(\""+nid+"\")'>✏️</button>";
|
||||
h+="<button class=mb-folder-btn onclick='moveNode(\""+nid+"\")'>📦</button>";
|
||||
h+="<button class=mb-folder-btn danger onclick=delNode(\""+nid+"\",\""+dname(n)+"\")>🗑</button></span>";;
|
||||
if(ch[n.node_id]&&ch[n.node_id].length){
|
||||
h+="<div id="+cid+" style=display:"+(ind==0?"block":"none")+">";
|
||||
h+=rc(n.node_id,ind+1)+"</div>";
|
||||
}
|
||||
}else{
|
||||
var nid=n.node_id;
|
||||
h+="<div class=mb-tree-node style=padding-left:"+(ind*20+18)+"px>";
|
||||
h+="<span class=mb-tree-label ondblclick='renameNode(\""+nid+"\")'>";
|
||||
h+="<span class=mb-tree-file onclick='showDetail(\""+(n.file_uuid||"")+"\")'>";
|
||||
h+=(n.icon||"📄")+" <b id=flb"+nid+">"+dname(n)+"</b></span>";
|
||||
h+="<span class=mb-tree-meta>"+fsize(n.file_size)+"</span>";
|
||||
h+="<span class=mb-folder-btn onclick='quickPreview(\""+(n.file_uuid||"")+"\")' title='Preview' style='display:inline-block;margin-left:4px;font-size:11px'>👁</span></span>";
|
||||
h+="<span class=mb-folder-actions>";
|
||||
h+="<button class=mb-folder-btn onclick='pickIcon(\""+nid+"\")'>🎨</button>";
|
||||
h+="<button class=mb-folder-btn onclick='renameNode(\""+nid+"\")'>✏️</button>";
|
||||
h+="<button class=mb-folder-btn onclick='moveNode(\""+nid+"\")'>📦</button>";
|
||||
h+="<button class=mb-folder-btn danger onclick=delNode(\""+nid+"\",\""+dname(n)+"\")>🗑</button></span>";;
|
||||
}
|
||||
h+="</div>";
|
||||
});
|
||||
return h;
|
||||
}
|
||||
return rc("root",0);
|
||||
}
|
||||
|
||||
function renderList(d){
|
||||
var h="<table style=width:100%><thead><tr><th>Name</th><th>file_uuid</th><th>Size</th><th>Type</th></tr></thead><tbody>";
|
||||
d.nodes.forEach(function(n){
|
||||
var icon=n.icon||(n.node_type=="folder"?"📁":"📄");
|
||||
var badge=n.node_type=="folder"?"<span style=color:#fbbf24>folder</span>":"<span style=color:#4ade80>file</span>";
|
||||
h+="<tr onclick='"+(n.file_uuid?"showDetail(\""+n.file_uuid+"\")":"")+"' style=cursor:"+(n.file_uuid?"pointer":"default")+">";
|
||||
h+="<td>"+icon+" "+dname(n)+"</td>";
|
||||
h+="<td><code>"+(n.file_uuid||"-")+"</code></td>";
|
||||
h+="<td>"+fsize(n.file_size)+"</td><td>"+badge+"</td></tr>";
|
||||
});
|
||||
h+="</tbody></table>";return h;
|
||||
}
|
||||
|
||||
function renderGrid(d,mode){
|
||||
var cls=mode=="grid_sm"?"sm":"lg";
|
||||
var h="<div class='mb-grid "+cls+"'>";
|
||||
d.nodes.forEach(function(n){
|
||||
var icon=n.icon||(n.node_type=="folder"?"📁":"📄");
|
||||
h+="<div class=mb-grid-cell onclick='"+(n.file_uuid?"showDetail(\""+n.file_uuid+"\")":"")+"'>";
|
||||
h+="<div class=mb-grid-icon>"+icon+"</div>";
|
||||
h+="<div class=mb-grid-label>"+dname(n)+"</div>";
|
||||
if(n.file_uuid)h+="<div class=mb-grid-uuid>"+n.file_uuid+"</div>";
|
||||
h+="</div>";
|
||||
});
|
||||
h+="</div>";return h;
|
||||
}
|
||||
|
||||
// DETAIL PANEL
|
||||
function showDetail(fuuid){
|
||||
if(!fuuid)return;
|
||||
document.getElementById("mb-overlay").style.display="block";
|
||||
document.getElementById("mb-detail").style.display="block";
|
||||
var b=document.getElementById("mb-detail-body");
|
||||
b.innerHTML="<div style=text-align:center;padding:30px;color:#64748b>Loading...</div>";
|
||||
fetch("/api/v2/files/"+fuuid+"/info").then(function(r){return r.json()}).then(function(d){
|
||||
var node=_td&&_td.nodes?_td.nodes.find(function(n){return n.file_uuid==fuuid}):null;
|
||||
var label=node?dname(node):fuuid;
|
||||
var sz=node?fsize(node.file_size):"-";
|
||||
var sha=node?(node.sha256||"-").substring(0,16)+"...":"-";
|
||||
var reg=node?(node.registered_at||"-").substring(0,10):"-";
|
||||
var h="<h2 style=border:none;margin-bottom:16px>📄 "+label+"</h2>";
|
||||
h+="<table style=width:100%><tr><th>file_uuid</th><td><code>"+d.file_uuid+"</code></td></tr>";
|
||||
h+="<tr><th>SHA256</th><td><code>"+sha+"</code></td></tr>";
|
||||
h+="<tr><th>Size</th><td>"+sz+"</td></tr>";
|
||||
h+="<tr><th>Registered</th><td>"+reg+"</td></tr></table>";
|
||||
h+="<h3>🌳 Virtual Paths</h3>";
|
||||
if(d.virtual_paths&&d.virtual_paths.length) d.virtual_paths.forEach(function(vp){h+="<div>📁 "+vp+"</div>"});
|
||||
else h+="<div style=color:#64748b>—</div>";
|
||||
h+="<h3>💾 Real Locations ("+d.location_count+")</h3>";
|
||||
if(d.real_locations&&d.real_locations.length){
|
||||
d.real_locations.forEach(function(loc){
|
||||
var lbl=loc.label||"",pth=loc.path||"";
|
||||
var tier="hot";
|
||||
if(lbl.indexOf("warm")>=0||lbl.indexOf("nas")>=0) tier="warm";
|
||||
if(lbl.indexOf("cold")>=0||lbl.indexOf("lto")>=0||lbl.indexOf("glacier")>=0) tier="cold";
|
||||
if(lbl.indexOf("s3")>=0||lbl.indexOf("cdn")>=0||lbl.indexOf("ipfs")>=0) tier="cloud";
|
||||
h+="<div><span class=mb-loc-tag "+tier+">"+lbl+"</span><code style=font-size:11px>"+pth.substring(0,80)+"</code></div>";
|
||||
});
|
||||
}else h+="<div style=color:#64748b>—</div>";
|
||||
h+="<h3>🔍 Probe Data <span id=mb-probe-status style=font-size:11px;color:#64748b>loading...</span></h3><div id=mb-probe-data style=color:#64748b>Loading...</div>";
|
||||
h+="<h3>🖼️ Preview <span style=font-size:12px;color:#94a3b8>"+label+"</span> <span id=mb-preview-res style=font-size:12px;color:#64748b></span></h3><div style=margin-top:8px;position:relative;display:flex;align-items:center;gap:8px>";
|
||||
var src="/api/v2/files/"+fuuid+"/stream";
|
||||
var ext=(label||"").split(".").pop().toLowerCase();
|
||||
var isVideo=(ext=="mp4"||ext=="mov"||ext=="avi"||ext=="webm"||ext=="mkv");
|
||||
var isTxt=(ext=="txt"||ext=="log"||ext=="csv"||ext=="json"||ext=="xml");
|
||||
var isMd=(ext=="md"||ext=="markdown");
|
||||
var isDocText=(ext=="docx"||ext=="doc"||ext=="rtf");
|
||||
var isDocImg=(ext=="pages"||ext=="key"||ext=="numbers");
|
||||
var isDocPdf=(ext=="pdf"||ext=="pptx"||ext=="xlsx"||ext=="odt"||ext=="xls"||ext=="ppt"||ext=="epub"||ext=="html");
|
||||
var isDoc=isDocText||isDocImg||isDocPdf;
|
||||
if(isVideo){
|
||||
h+="<video controls style='max-width:100%;max-height:360px;border-radius:8px;background:#0f172a'><source src='"+src+"'></video>";
|
||||
}else if(isTxt||isDocText){
|
||||
h+="<pre id=mb-detail-txt style='max-height:300px;overflow:auto;background:#0f172a;color:#e2e8f0;padding:12px;border-radius:8px;font-size:13px;white-space:pre-wrap;width:100%'>Loading...</pre>";
|
||||
}else if(isMd){
|
||||
h+="<div id=mb-md-render style='max-height:400px;overflow:auto;background:#0f172a;padding:12px;border-radius:8px'>Loading...</div>";
|
||||
}else if(isDocImg){
|
||||
h+="<img id=mb-preview-img src='"+src+"' style='max-width:100%;max-height:400px;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
|
||||
}else if(isDocPdf){
|
||||
h+="<iframe sandbox='allow-same-origin' src='"+src+"' style='width:100%;height:400px;border:none;border-radius:8px;background:#fff'></iframe>";
|
||||
}else{
|
||||
h+="<button id=mb-prev-btn onclick=navigatePhoto('prev') style='background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:24px;padding:8px 14px;border-radius:6px;cursor:pointer'>◀</button>";
|
||||
h+="<img id=mb-preview-img src='"+src+"' style='max-width:100%;max-height:400px;min-height:100px;min-width:100px;border-radius:8px;background:#0f172a' onerror=\"this.onerror=null;this.alt='No preview'\">";
|
||||
h+="<button id=mb-next-btn onclick=navigatePhoto('next') style='background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:24px;padding:8px 14px;border-radius:6px;cursor:pointer'>▶</button>";
|
||||
h+="<span id=mb-photo-pos style='position:absolute;bottom:8px;right:50px;background:rgba(0,0,0,.7);color:#94a3b8;padding:2px 8px;border-radius:4px;font-size:11px'>1/1</span>";
|
||||
}
|
||||
h+="</div>";
|
||||
b.innerHTML=h;
|
||||
if(!isVideo&&!isTxt&&!isMd&&!isDocText&&!isDocImg&&!isDocPdf){_photoUuid=fuuid;setupPhotoNav(fuuid)}
|
||||
if(isTxt||isDocText) fetch(src).then(function(r){return r.text()}).then(function(t){
|
||||
var el=document.getElementById("mb-detail-txt");if(el)el.textContent=t||"(empty)";
|
||||
});
|
||||
if(isMd) fetch("/api/v2/render/"+fuuid+"/body").then(function(r){return r.text()}).then(function(h){
|
||||
var el=document.getElementById("mb-md-render");if(el){el.innerHTML=h;setTimeout(function(){
|
||||
var nodes=el.querySelectorAll(".mermaid");if(nodes.length)mermaid.run({nodes:Array.from(nodes)})
|
||||
},100)}
|
||||
});
|
||||
fetch("/api/v2/files/"+fuuid+"/probe").then(function(r){return r.json()}).then(function(p){
|
||||
var pd=document.getElementById("mb-probe-data");
|
||||
var ps=document.getElementById("mb-probe-status");
|
||||
var pr=document.getElementById("mb-preview-res");
|
||||
if(p.width&&p.height)pr.textContent="("+p.width+"×"+p.height+")";
|
||||
else pr.textContent="";
|
||||
if(p.probe){
|
||||
ps.textContent="";
|
||||
var ph="<table style=width:100%>";
|
||||
if(p.duration)ph+="<tr><th>Duration</th><td>"+(p.duration).toFixed(1)+"s ("+Math.floor(p.duration/60)+"min "+(p.duration%60).toFixed(0)+"s)</td></tr>";
|
||||
if(p.width&&p.height)ph+="<tr><th>Resolution</th><td>"+p.width+"×"+p.height+"</td></tr>";
|
||||
if(p.fps)ph+="<tr><th>FPS</th><td>"+p.fps+"</td></tr>";
|
||||
if(p.file_type)ph+="<tr><th>Codec</th><td>"+p.file_type+"</td></tr>";
|
||||
if(p.total_frames)ph+="<tr><th>Total Frames</th><td>"+p.total_frames+"</td></tr>";
|
||||
if(p.probe.format){
|
||||
var fmt=p.probe.format;
|
||||
if(fmt.format_name)ph+="<tr><th>Format</th><td>"+fmt.format_name+"</td></tr>";
|
||||
if(fmt.size)ph+="<tr><th>Probe Size</th><td>"+fmt.size+"</td></tr>";
|
||||
if(fmt.bit_rate)ph+="<tr><th>Bitrate</th><td>"+(fmt.bit_rate/1000000).toFixed(1)+" Mbps</td></tr>";
|
||||
}
|
||||
if(p.probe.streams){
|
||||
ph+="<tr><th>Streams</th><td>";
|
||||
p.probe.streams.forEach(function(s){ph+=" • "+s.codec_type+": "+s.codec_name+"<br>"});
|
||||
ph+="</td></tr>";
|
||||
}
|
||||
ph+="</table>";pd.innerHTML=ph;
|
||||
}else{
|
||||
ps.textContent="(not available)";
|
||||
pd.innerHTML="<div style=color:#64748b>No probe data for this file</div>";
|
||||
}
|
||||
}).catch(function(){document.getElementById("mb-probe-status").textContent="(error)"});
|
||||
}).catch(function(e){b.innerHTML="<div style=color:#ef4444>Error: "+e+"</div>"});
|
||||
}
|
||||
|
||||
function closeDetail(){
|
||||
document.getElementById("mb-overlay").style.display="none";
|
||||
document.getElementById("mb-detail").style.display="none";
|
||||
}
|
||||
|
||||
// PHOTO NAVIGATION
|
||||
var _photoUuid=null,_photoList=[];
|
||||
|
||||
function setupPhotoNav(fuuid){
|
||||
if(!_td) return;
|
||||
var imgs=["jpg","jpeg","png","gif","bmp","webp","tiff","svg"];
|
||||
_photoList=_td.nodes.filter(function(n){
|
||||
if(!n.file_uuid||n.node_type!="file")return false;
|
||||
var e=(n.label||"").split(".").pop().toLowerCase();
|
||||
return imgs.indexOf(e)>=0;
|
||||
}).map(function(n){return n.file_uuid});
|
||||
updatePhotoPos(fuuid);
|
||||
}
|
||||
|
||||
function updatePhotoPos(fuuid){
|
||||
_photoUuid=fuuid;
|
||||
var idx=_photoList.indexOf(fuuid);
|
||||
var pos=document.getElementById("mb-photo-pos");
|
||||
if(pos)pos.textContent=(idx+1)+"/"+_photoList.length;
|
||||
var prev=document.getElementById("mb-prev-btn");
|
||||
var next=document.getElementById("mb-next-btn");
|
||||
if(prev)prev.style.visibility=idx>0?"visible":"hidden";
|
||||
if(next)next.style.visibility=idx<_photoList.length-1?"visible":"hidden";
|
||||
}
|
||||
|
||||
function navigatePhoto(dir){
|
||||
var idx=_photoList.indexOf(_photoUuid);
|
||||
if(dir=="prev"&&idx>0)idx--;else if(dir=="next"&&idx<_photoList.length-1)idx++;
|
||||
if(idx>=0&&idx<_photoList.length){
|
||||
_photoUuid=_photoList[idx];
|
||||
var img=document.getElementById("mb-preview-img");
|
||||
if(img)img.src="/api/v2/files/"+_photoUuid+"/stream?_="+Date.now();
|
||||
updatePhotoPos(_photoUuid);
|
||||
}
|
||||
}
|
||||
|
||||
// TOAST
|
||||
function toast(msg){
|
||||
var t=document.createElement("div");t.className="mb-toast";t.textContent=msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},300)},1500);
|
||||
}
|
||||
|
||||
// ICON PICKER
|
||||
var ICONS=["📁","📂","📄","🎬","🎵","🖼️","📊","📝","📦","📸","🎨","📹","🎧","📚","🔧","⚙️","🌐","💾","📀","💿","🏠","🏢","🌟","⭐","💡","🔥","❤️","💚","💙","💛","🧡","💜","✅","❌","⚠️","🔒","🔓","🔑","📡","🔗"];
|
||||
var _icoPicked=null,_icoNode=null;
|
||||
|
||||
function pickIcon(nid){
|
||||
_icoPicked=null;_icoNode=nid;
|
||||
var o=document.createElement("div");
|
||||
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:10002;display:flex;align-items:center;justify-content:center";
|
||||
var bx=document.createElement("div");
|
||||
bx.style.cssText="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;min-width:380px;max-width:440px";
|
||||
var h="<h3>🎨 Pick Icon</h3><div class=mb-icon-picker id=mb-ipg>";
|
||||
ICONS.forEach(function(ico,idx){h+="<button id=ipi"+idx+" onclick='selIcon("+idx+",\""+ico+"\")'>"+ico+"</button>"});
|
||||
h+="</div>Custom: <input id=mbci placeholder='emoji or SVG url' style='width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;padding:6px 10px;font-size:18px;margin-top:8px' oninput='_icoPicked=document.getElementById(\"mbci\").value'>";
|
||||
h+="<div style=margin-top:10px;display:flex;gap:6px;justify-content:flex-end>";
|
||||
h+="<button id=idef style='background:#451a03;border:none;color:#fbbf24;padding:4px 14px;border-radius:6px;cursor:pointer'>Default</button>";
|
||||
h+="<button id=icxl style='background:#334155;border:none;color:#94a3b8;padding:4px 14px;border-radius:6px;cursor:pointer'>Cancel</button>";
|
||||
h+="<button id=iok style='background:#064e3b;border:none;color:#4ade80;padding:4px 14px;border-radius:6px;cursor:pointer'>OK</button>";
|
||||
h+="</div>";
|
||||
bx.innerHTML=h;o.appendChild(bx);document.body.appendChild(o);
|
||||
document.getElementById("iok").onclick=function(){if(_icoPicked)applyIcon(_icoNode,_icoPicked);o.remove()};
|
||||
document.getElementById("idef").onclick=function(){applyIcon(_icoNode,"");o.remove()};
|
||||
document.getElementById("icxl").onclick=function(){o.remove()};
|
||||
o.onclick=function(e){if(e.target==o)o.remove()};
|
||||
}
|
||||
|
||||
function selIcon(idx,ico){
|
||||
_icoPicked=ico;
|
||||
ICONS.forEach(function(_,i){var el=document.getElementById("ipi"+i);if(el)el.style.border=i==idx?"2px solid #4ade80":"2px solid transparent"});
|
||||
}
|
||||
|
||||
function applyIcon(nid,ico){
|
||||
fetch("/api/v2/tree/demo/node/"+nid,{method:"PUT",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({icon:ico})})
|
||||
.then(function(r){return r.json()}).then(function(){
|
||||
loadTree();toast(ico?"Icon → "+ico:"Icon reset to default");
|
||||
});
|
||||
}
|
||||
|
||||
// AGENT ORGANIZE
|
||||
function organizeTree(){
|
||||
if(!_td){toast("Load tree first");return}
|
||||
var folders={};
|
||||
_td.nodes.forEach(function(n){if(n.node_type=="folder")folders[n.label]=n.node_id});
|
||||
var cats=["Movies","Marketing","Cartoons","Other"];
|
||||
var targets={};
|
||||
cats.forEach(function(c){if(folders[c])targets[c]=folders[c]});
|
||||
if(Object.keys(targets).length<2){toast("Need at least 2 category folders");return}
|
||||
|
||||
var files=_td.nodes.filter(function(n){
|
||||
if(n.node_type!="file"||!n.parent_id)return false;
|
||||
// Already in a category folder?
|
||||
var inCat=false;
|
||||
for(var k in targets){if(n.parent_id==targets[k]){inCat=true;break}}
|
||||
return !inCat;
|
||||
});
|
||||
if(!files.length){toast("All files already organized ✓");return}
|
||||
|
||||
var mover=function(idx){
|
||||
if(idx>=files.length){loadTree();toast("Agent: "+files.length+" files organized");return}
|
||||
var f=files[idx];
|
||||
var nl=(f.label||"").toLowerCase();
|
||||
var t="Other";
|
||||
if(/charade|film|clip|movie|comedy|filmriot/.test(nl))t="Movies";
|
||||
else if(/exasan|gamma|thunderbolt|nab|koba|webinar|top colorist|accusys|a12t3/.test(nl))t="Marketing";
|
||||
else if(/cartoon|alice|felix|disney|steamboat|animal/.test(nl))t="Cartoons";
|
||||
var pid=targets[t];
|
||||
if(!pid){mover(idx+1);return}
|
||||
fetch("/api/v2/tree/demo/node/"+f.node_id+"/move",{method:"PUT",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({parent_id:pid})})
|
||||
.then(function(r){return r.json()}).then(function(){mover(idx+1);})
|
||||
.catch(function(){mover(idx+1);});
|
||||
};
|
||||
mover(0);
|
||||
}
|
||||
|
||||
// DUPLICATE FINDER
|
||||
function findDupes(){
|
||||
var o=document.createElement("div");
|
||||
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:10002;display:flex;align-items:center;justify-content:center";
|
||||
var bx=document.createElement("div");
|
||||
bx.style.cssText="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;min-width:500px;max-width:700px;max-height:80vh;overflow-y:auto";
|
||||
bx.innerHTML="<h3>🔍 Scanning for duplicates...</h3>";
|
||||
o.appendChild(bx);document.body.appendChild(o);
|
||||
o.onclick=function(e){if(e.target==o)o.remove()};
|
||||
|
||||
fetch("/api/v2/dupes/demo").then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.dupes||!d.dupes.length){
|
||||
bx.innerHTML="<h3>✅ No Duplicates Found</h3><button onclick=this.parentElement.parentElement.remove() style='margin-top:12px;background:#334155;border:none;color:#94a3b8;padding:6px 16px;border-radius:6px;cursor:pointer'>Close</button>";
|
||||
return;
|
||||
}
|
||||
var h="<h3>🔍 Duplicates ("+d.dup_groups+" groups)</h3>";
|
||||
d.dupes.forEach(function(g,i){
|
||||
h+="<div style='margin:10px 0;padding:10px;background:#0f172a;border-radius:8px'>";
|
||||
h+="<b>"+(i+1)+". "+g.file_name+"</b> ("+g.count+"x)<br>";
|
||||
g.uuids.forEach(function(uuid,j){
|
||||
var st=g.statuses[j]||"?";
|
||||
var badge=st=="completed"?"background:#064e3b;color:#4ade80":st=="pending"?"background:#451a03;color:#fbbf24":"background:#1e293b;color:#64748b";
|
||||
h+="<div style='display:flex;align-items:center;gap:8px;margin:4px 0;padding:4px 8px;background:#1e293b;border-radius:4px'>";
|
||||
h+="<code style='flex:1;font-size:11px'>"+uuid+"</code>";
|
||||
h+="<span style='padding:1px 6px;border-radius:3px;font-size:10px;"+badge+"'>"+st+"</span>";
|
||||
h+="<button onclick=unregDup('"+uuid+"') style='background:#451a03;border:none;color:#fbbf24;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px'>✕</button>";
|
||||
h+="</div>";
|
||||
});
|
||||
h+="</div>";
|
||||
});
|
||||
h+="<div style=margin-top:12px;display:flex;gap:6px;justify-content:flex-end><button onclick=this.parentElement.parentElement.parentElement.remove() style='background:#334155;border:none;color:#94a3b8;padding:4px 14px;border-radius:6px;cursor:pointer'>Close</button><button onclick='afterUnreg()' style='background:#1e3a5f;border:none;color:#93c5fd;padding:4px 14px;border-radius:6px;cursor:pointer'>Restore Tree</button></div>";
|
||||
bx.innerHTML=h;
|
||||
});
|
||||
}
|
||||
|
||||
function unregDup(uuid){
|
||||
if(!confirm("Unregister "+uuid+" ?"))return;
|
||||
fetch("/api/v2/unregister/"+uuid,{method:"POST"})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
toast("Unregistered: "+uuid);
|
||||
findDupes(); // re-scan
|
||||
});
|
||||
}
|
||||
|
||||
function afterUnreg(){
|
||||
document.querySelectorAll("div[style*='z-index:10002']").forEach(function(el){el.remove()});
|
||||
restoreTree();
|
||||
}
|
||||
|
||||
function uploadFile(input){
|
||||
var file=input.files[0];
|
||||
if(!file)return;
|
||||
var fd=new FormData();
|
||||
fd.append("file",file);
|
||||
toast("Uploading "+file.name+"...");
|
||||
fetch("/api/v2/upload/demo",{method:"POST",body:fd})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
if(d.ok){toast("Uploaded: "+d.filename+" ("+fsize(d.size)+")");loadTree()}
|
||||
else toast("Upload failed: "+(d.error||"unknown"));
|
||||
}).catch(function(e){toast("Upload error: "+e)});
|
||||
input.value="";
|
||||
}
|
||||
|
||||
// QUICK PREVIEW
|
||||
function quickPreview(fuuid){
|
||||
if(!fuuid)return;
|
||||
var node=(_td&&_td.nodes)?_td.nodes.find(function(n){return n.file_uuid==fuuid}):null;
|
||||
var label=node?node.label:"";
|
||||
var ext=(label||"").split(".").pop().toLowerCase();
|
||||
var isVideo=ext=="mp4"||ext=="mov"||ext=="avi"||ext=="webm"||ext=="mkv";
|
||||
var isTxt=ext=="txt"||ext=="log"||ext=="csv"||ext=="json"||ext=="xml";
|
||||
var isMd=(ext=="md"||ext=="markdown");
|
||||
var isDocText=(ext=="docx"||ext=="doc"||ext=="rtf");
|
||||
var isDocImg=(ext=="pages"||ext=="key"||ext=="numbers");
|
||||
var isDocPdf=(ext=="pdf"||ext=="pptx"||ext=="xlsx"||ext=="odt"||ext=="xls"||ext=="ppt"||ext=="epub"||ext=="html");
|
||||
var src="/api/v2/files/"+fuuid+"/stream";
|
||||
|
||||
var o=document.createElement("div");
|
||||
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.8);z-index:10002;display:flex;align-items:center;justify-content:center";
|
||||
var inner="";
|
||||
if(isVideo){
|
||||
inner="<video controls autoplay style='max-width:90vw;max-height:85vh;border-radius:8px'><source src='"+src+"'></video>";
|
||||
}else if(isTxt||isDocText){
|
||||
inner="<pre id=mb-txt-preview style='max-width:90vw;max-height:85vh;overflow:auto;background:#0f172a;color:#e2e8f0;padding:24px;border-radius:8px;font-size:14px;white-space:pre-wrap'>Loading...</pre>";
|
||||
}else if(isMd){
|
||||
inner="<div id=mb-qmd-render style='max-width:90vw;max-height:85vh;overflow:auto;background:#0f172a;padding:24px;border-radius:8px'>Loading...</div>";
|
||||
}else if(isDocImg){
|
||||
inner="<img src='"+src+"' style='max-width:90vw;max-height:85vh;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
|
||||
}else if(isDocPdf){
|
||||
inner="<iframe sandbox='allow-same-origin' src='"+src+"' style='width:90vw;height:85vh;border:none;border-radius:8px;background:#fff'></iframe>";
|
||||
}else{
|
||||
inner="<img src='"+src+"' style='max-width:90vw;max-height:85vh;min-height:100px;min-width:100px;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
|
||||
}
|
||||
inner+="<div style='position:absolute;top:16px;right:16px'><button onclick=this.parentElement.parentElement.remove() style='background:rgba(0,0,0,.6);border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px;border-radius:6px'>✕</button></div>";
|
||||
o.innerHTML=inner;
|
||||
document.body.appendChild(o);
|
||||
if(isTxt||isDocText) fetch(src).then(function(r){return r.text()}).then(function(t){
|
||||
var el=document.getElementById("mb-txt-preview");
|
||||
if(el)el.textContent=t||"(empty)";
|
||||
});
|
||||
if(isMd) fetch("/api/v2/render/"+fuuid+"/body").then(function(r){return r.text()}).then(function(h){
|
||||
var el=document.getElementById("mb-qmd-render");if(el){el.innerHTML=h;setTimeout(function(){
|
||||
var nodes=el.querySelectorAll(".mermaid");if(nodes.length)mermaid.run({nodes:Array.from(nodes)})
|
||||
},100)}
|
||||
});
|
||||
o.onclick=function(e){if(e.target==o)o.remove()};
|
||||
}
|
||||
|
||||
// LOCK TOGGLE
|
||||
var _locked=true;
|
||||
function toggleLock(){
|
||||
_locked=!_locked;
|
||||
localStorage.setItem("tree_locked",_locked?"1":"0");
|
||||
document.body.classList.toggle("mb-locked",_locked);
|
||||
var icon=document.getElementById("mb-lock-icon");
|
||||
if(icon)icon.textContent=_locked?"🔒":"🔓";
|
||||
loadTree();
|
||||
}
|
||||
|
||||
// Init
|
||||
(function(){
|
||||
var s=localStorage.getItem("display_mode");
|
||||
if(s&&["tree","list","grid_sm","grid_lg"].indexOf(s)>=0)_tm=s;
|
||||
_locked=localStorage.getItem("tree_locked")!=="0";
|
||||
document.body.classList.toggle("mb-locked",_locked);
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
31
src/render.rs
Normal file
31
src/render.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use pulldown_cmark::{html, Options, Parser};
|
||||
|
||||
pub fn md_to_html(content: &str) -> String {
|
||||
let mut opts = Options::empty();
|
||||
opts.insert(Options::ENABLE_TABLES);
|
||||
opts.insert(Options::ENABLE_FOOTNOTES);
|
||||
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
opts.insert(Options::ENABLE_TASKLISTS);
|
||||
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
let parser = Parser::new_ext(content, opts);
|
||||
let mut body = String::new();
|
||||
html::push_html(&mut body, parser);
|
||||
body
|
||||
}
|
||||
|
||||
const HTML: &str = include_str!("page.html");
|
||||
|
||||
pub fn page(title: &str, content: &str) -> String {
|
||||
HTML.replace("{__TITLE__}", title)
|
||||
.replace("{__CONTENT__}", content)
|
||||
}
|
||||
|
||||
pub fn render_page(title: &str, content: &str) -> String {
|
||||
let content = content
|
||||
.replace(
|
||||
"<code class=\"language-mermaid\">",
|
||||
"<div class=\"mermaid\">",
|
||||
)
|
||||
.replace("</code>", "</div>");
|
||||
page(title, &content).replace("startOnLoad:false", "startOnLoad:true")
|
||||
}
|
||||
1198
src/server.rs
Normal file
1198
src/server.rs
Normal file
File diff suppressed because it is too large
Load Diff
248
tests/api_logic_test.rs
Normal file
248
tests/api_logic_test.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
// API測試 - 使用整合測試方式
|
||||
// 因handler函數未公開,使用FileTree直接測試API邏輯
|
||||
|
||||
use markbase::filetree::node::NodeType;
|
||||
use markbase::filetree::{mode, FileTree};
|
||||
use rusqlite::Connection;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_db() -> (Connection, String) {
|
||||
let user_id = format!("test_api_{}", Uuid::new_v4());
|
||||
let conn = FileTree::init_user_db(&user_id).unwrap();
|
||||
(conn, user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_create_folder() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("API_Test_Folder", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_create_file() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let (file_node, register_sql) = FileTree::new_file_node(
|
||||
"test_api.mp4",
|
||||
"api_test_uuid",
|
||||
None,
|
||||
"test_api.mp4",
|
||||
Some(1024),
|
||||
Some("video/mp4"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if let Some(sql) = register_sql {
|
||||
conn.execute_batch(&sql).unwrap();
|
||||
}
|
||||
|
||||
tree.insert_node(&conn, &file_node).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 1);
|
||||
assert_eq!(loaded.nodes[0].node_type, NodeType::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_get_tree_with_mode() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Root", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
//測試不同顯示模式
|
||||
let tree_mode = mode::get_mode("tree").unwrap();
|
||||
let tree_rendered = tree_mode.render(&tree);
|
||||
assert!(tree_rendered["nodes"].is_array());
|
||||
|
||||
let list_mode = mode::get_mode("list").unwrap();
|
||||
let list_rendered = list_mode.render(&tree);
|
||||
assert!(list_rendered["nodes"].is_array());
|
||||
|
||||
let grid_sm_mode = mode::get_mode("grid_sm").unwrap();
|
||||
let grid_sm_rendered = grid_sm_mode.render(&tree);
|
||||
assert!(grid_sm_rendered["nodes"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_update_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let mut folder = FileTree::new_folder("Original", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
folder.label = "Updated_via_API".to_string();
|
||||
folder.icon = Some("🎬".to_string());
|
||||
tree.update_node(&conn, &folder.node_id, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes[0].label, "Updated_via_API");
|
||||
assert_eq!(loaded.nodes[0].icon, Some("🎬".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_delete_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("ToDelete", None);
|
||||
let node_id = folder.node_id.clone();
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
assert_eq!(tree.nodes.len(), 1);
|
||||
|
||||
tree.delete_node(&conn, &node_id).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_delete_all_nodes() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
for i in 1..=5 {
|
||||
let folder = FileTree::new_folder(&format!("Folder{}", i), None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
}
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 5);
|
||||
|
||||
//模擬delete_all_nodes API邏輯
|
||||
for node in &loaded.nodes {
|
||||
conn.execute(
|
||||
"DELETE FROM file_nodes WHERE node_id = ?1",
|
||||
rusqlite::params![node.node_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let after_delete = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(after_delete.nodes.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_move_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let parent = FileTree::new_folder("Parent", None);
|
||||
let child = FileTree::new_folder("Child", Some(parent.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &parent).unwrap();
|
||||
tree.insert_node(&conn, &child).unwrap();
|
||||
|
||||
//移動到根目錄
|
||||
tree.move_node(&conn, &child.node_id, None).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let moved_node = loaded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.node_id == child.node_id)
|
||||
.unwrap();
|
||||
assert!(moved_node.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_update_alias() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.update_node_alias(&conn, &folder.node_id, "zh_tw", "影片")
|
||||
.unwrap();
|
||||
tree.update_node_alias(&conn, &folder.node_id, "en_us", "Videos")
|
||||
.unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(
|
||||
loaded.nodes[0].aliases.get("zh_tw").map(|s| s.as_str()),
|
||||
Some("影片")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.nodes[0].aliases.get("en_us").map(|s| s.as_str()),
|
||||
Some("Videos")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_nested_structure() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let level1 = FileTree::new_folder("Level1", Some(root.node_id.clone()));
|
||||
let level2 = FileTree::new_folder("Level2", Some(level1.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &level1).unwrap();
|
||||
tree.insert_node(&conn, &level2).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_get_modes() {
|
||||
let modes = mode::list_modes();
|
||||
|
||||
assert_eq!(modes.len(), 4);
|
||||
|
||||
let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
|
||||
assert!(names.contains(&"tree"));
|
||||
assert!(names.contains(&"list"));
|
||||
assert!(names.contains(&"grid_sm"));
|
||||
assert!(names.contains(&"grid_lg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_file_info() {
|
||||
let (conn, user_id) = temp_db();
|
||||
|
||||
let file_uuid = "test_file_uuid_123";
|
||||
FileTree::add_location(&conn, file_uuid, "/path/to/file.mp4", Some("origin")).unwrap();
|
||||
|
||||
let location: String = conn
|
||||
.query_row(
|
||||
"SELECT location FROM file_locations WHERE file_uuid = ?1",
|
||||
[file_uuid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(location, "/path/to/file.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_logic_restore_scenario() {
|
||||
//模擬restore_tree API邏輯
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
//建立基本結構
|
||||
let home = FileTree::new_folder("Home", None);
|
||||
let movies = FileTree::new_folder("Movies", Some(home.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &home).unwrap();
|
||||
tree.insert_node(&conn, &movies).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert!(loaded.nodes.iter().any(|n| n.label == "Home"));
|
||||
assert!(loaded.nodes.iter().any(|n| n.label == "Movies"));
|
||||
}
|
||||
40
tests/audio_test.rs
Normal file
40
tests/audio_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use markbase::audio::{phrase_for_lang, voice_for_lang};
|
||||
|
||||
#[test]
|
||||
fn test_voice_for_lang_zh_tw() {
|
||||
assert_eq!(voice_for_lang("zh_TW"), "Meijia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_voice_for_lang_en_us() {
|
||||
assert_eq!(voice_for_lang("en_US"), "Samantha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_voice_for_lang_unknown() {
|
||||
assert_eq!(voice_for_lang("unknown"), "Meijia"); //默認為Meijia
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phrase_for_lang_zh_tw() {
|
||||
assert_eq!(phrase_for_lang("zh_TW"), "語音測試一二三");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phrase_for_lang_en_us() {
|
||||
assert_eq!(phrase_for_lang("en_US"), "Test one two three");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_audio_devices_macos() {
|
||||
use markbase::audio::audio_devices;
|
||||
|
||||
let (out, inp, co, ci) = audio_devices();
|
||||
|
||||
//至少應該有一些輸出裝置(即使是內建的)
|
||||
assert!(out.len() > 0 || inp.len() > 0);
|
||||
|
||||
// current應該是有效的字符串
|
||||
assert!(!co.is_empty() || !ci.is_empty());
|
||||
}
|
||||
48
tests/command_test.rs
Normal file
48
tests/command_test.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use markbase::command::{get_commands, post_command};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_post_command_basic() {
|
||||
let body = json!({"cmd": "test_cmd", "val": "test_value"});
|
||||
let response = post_command(Json(body)).await;
|
||||
|
||||
let response_json = response.into_response();
|
||||
//驗證響應狀態碼
|
||||
assert_eq!(response_json.status(), axum::http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_post_command_voice() {
|
||||
let body = json!({"cmd": "test_voice", "val": "zh_TW", "out": "Display Audio"});
|
||||
let response = post_command(Json(body)).await;
|
||||
|
||||
let response_json = response.into_response();
|
||||
assert_eq!(response_json.status(), axum::http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_post_command_vol_up() {
|
||||
let body = json!({"cmd": "vol_up"});
|
||||
let response = post_command(Json(body)).await;
|
||||
|
||||
let response_json = response.into_response();
|
||||
assert_eq!(response_json.status(), axum::http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_post_command_vol_down() {
|
||||
let body = json!({"cmd": "vol_down"});
|
||||
let response = post_command(Json(body)).await;
|
||||
|
||||
let response_json = response.into_response();
|
||||
assert_eq!(response_json.status(), axum::http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_commands_empty() {
|
||||
let response = get_commands().await;
|
||||
let response_json = response.into_response();
|
||||
|
||||
assert_eq!(response_json.status(), axum::http::StatusCode::OK);
|
||||
}
|
||||
64
tests/convert_test.rs
Normal file
64
tests/convert_test.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use markbase::filetree::convert::{is_apple_format_ext, is_document_ext, is_textutil_ext};
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_textutil() {
|
||||
assert!(is_document_ext("docx"));
|
||||
assert!(is_document_ext("doc"));
|
||||
assert!(is_document_ext("rtf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_apple() {
|
||||
assert!(is_document_ext("pages"));
|
||||
assert!(is_document_ext("key"));
|
||||
assert!(is_document_ext("numbers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_soffice() {
|
||||
assert!(is_document_ext("pptx"));
|
||||
assert!(is_document_ext("ppt"));
|
||||
assert!(is_document_ext("xlsx"));
|
||||
assert!(is_document_ext("xls"));
|
||||
assert!(is_document_ext("odt"));
|
||||
assert!(is_document_ext("epub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_false() {
|
||||
assert!(!is_document_ext("mp4"));
|
||||
assert!(!is_document_ext("jpg"));
|
||||
assert!(!is_document_ext("txt"));
|
||||
assert!(!is_document_ext("pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_textutil_ext() {
|
||||
assert!(is_textutil_ext("docx"));
|
||||
assert!(is_textutil_ext("doc"));
|
||||
assert!(is_textutil_ext("rtf"));
|
||||
assert!(!is_textutil_ext("pages"));
|
||||
assert!(!is_textutil_ext("mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_apple_format_ext() {
|
||||
assert!(is_apple_format_ext("pages"));
|
||||
assert!(is_apple_format_ext("key"));
|
||||
assert!(is_apple_format_ext("numbers"));
|
||||
assert!(!is_apple_format_ext("docx"));
|
||||
assert!(!is_apple_format_ext("mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_case_insensitive() {
|
||||
//測試小寫(convert.rs使用小寫比較)
|
||||
assert!(is_document_ext("docx"));
|
||||
assert!(is_document_ext("DOCX") == false); //函數未轉小寫,直接比較
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_document_ext_empty() {
|
||||
assert!(!is_document_ext(""));
|
||||
assert!(!is_document_ext("unknown"));
|
||||
}
|
||||
252
tests/filetree_api_test.rs
Normal file
252
tests/filetree_api_test.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use markbase::filetree::{node::NodeType, FileTree};
|
||||
use rusqlite::Connection;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_db() -> (Connection, String) {
|
||||
let user_id = format!("test_{}", Uuid::new_v4());
|
||||
let conn = FileTree::init_user_db(&user_id).unwrap();
|
||||
(conn, user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_create_folder_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let label = "TestFolder";
|
||||
let node_type = NodeType::Folder;
|
||||
|
||||
let folder = FileTree::new_folder(label, None);
|
||||
let node_id = folder.node_id.clone();
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let found = loaded.nodes.iter().find(|n| n.node_id == node_id);
|
||||
|
||||
assert!(found.is_some());
|
||||
let found_node = found.unwrap();
|
||||
assert_eq!(found_node.label, label);
|
||||
assert_eq!(found_node.node_type, node_type);
|
||||
assert!(found_node.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_create_file_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let parent = FileTree::new_folder("Parent", None);
|
||||
tree.insert_node(&conn, &parent).unwrap();
|
||||
|
||||
let (file_node, register_sql) = FileTree::new_file_node(
|
||||
"test.mp4",
|
||||
"abc123def456",
|
||||
Some("sha256hash"),
|
||||
"test.mp4",
|
||||
Some(1024000),
|
||||
Some("video/mp4"),
|
||||
None,
|
||||
Some(parent.node_id.clone()),
|
||||
);
|
||||
|
||||
if let Some(sql) = register_sql {
|
||||
conn.execute_batch(&sql).unwrap();
|
||||
}
|
||||
|
||||
let node_id = file_node.node_id.clone();
|
||||
tree.insert_node(&conn, &file_node).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let found = loaded.nodes.iter().find(|n| n.node_id == node_id);
|
||||
|
||||
assert!(found.is_some());
|
||||
let found_node = found.unwrap();
|
||||
assert_eq!(found_node.label, "test.mp4");
|
||||
assert_eq!(found_node.node_type, NodeType::File);
|
||||
assert_eq!(found_node.parent_id, Some(parent.node_id.clone()));
|
||||
assert_eq!(found_node.file_size, Some(1024000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_update_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let mut folder = FileTree::new_folder("Original", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
folder.label = "Updated".to_string();
|
||||
folder.icon = Some("📁".to_string());
|
||||
folder.color = Some("#ff0000".to_string());
|
||||
|
||||
tree.update_node(&conn, &folder.node_id, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let found = loaded.nodes.iter().find(|n| n.node_id == folder.node_id);
|
||||
|
||||
assert!(found.is_some());
|
||||
let found_node = found.unwrap();
|
||||
assert_eq!(found_node.label, "Updated");
|
||||
assert_eq!(found_node.icon, Some("📁".to_string()));
|
||||
assert_eq!(found_node.color, Some("#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_delete_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("ToDelete", None);
|
||||
let node_id = folder.node_id.clone();
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.delete_node(&conn, &node_id).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let found = loaded.nodes.iter().find(|n| n.node_id == node_id);
|
||||
|
||||
assert!(found.is_none(), "deleted node should not be found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_move_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child = FileTree::new_folder("Child", Some(root.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child).unwrap();
|
||||
|
||||
tree.move_node(&conn, &child.node_id, None).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let moved = loaded.nodes.iter().find(|n| n.node_id == child.node_id);
|
||||
|
||||
assert!(moved.is_some());
|
||||
assert!(
|
||||
moved.unwrap().parent_id.is_none(),
|
||||
"moved node should have no parent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_update_alias() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.update_node_alias(&conn, &folder.node_id, "zh_tw", "影片")
|
||||
.unwrap();
|
||||
tree.update_node_alias(&conn, &folder.node_id, "en_us", "Videos")
|
||||
.unwrap();
|
||||
tree.update_node_alias(&conn, &folder.node_id, "ja_jp", "動画")
|
||||
.unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let found = loaded.nodes.iter().find(|n| n.node_id == folder.node_id);
|
||||
|
||||
assert!(found.is_some());
|
||||
let found_node = found.unwrap();
|
||||
assert_eq!(
|
||||
found_node.aliases.get("zh_tw").map(|s| s.as_str()),
|
||||
Some("影片")
|
||||
);
|
||||
assert_eq!(
|
||||
found_node.aliases.get("en_us").map(|s| s.as_str()),
|
||||
Some("Videos")
|
||||
);
|
||||
assert_eq!(
|
||||
found_node.aliases.get("ja_jp").map(|s| s.as_str()),
|
||||
Some("動画")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_get_tree() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone()));
|
||||
let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child1).unwrap();
|
||||
tree.insert_node(&conn, &child2).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 3);
|
||||
assert_eq!(loaded.user_id, user_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_build_tree_structure() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone()));
|
||||
let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone()));
|
||||
let grandchild = FileTree::new_folder("Grandchild", Some(child1.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child1).unwrap();
|
||||
tree.insert_node(&conn, &child2).unwrap();
|
||||
tree.insert_node(&conn, &grandchild).unwrap();
|
||||
|
||||
let roots = tree.build_tree();
|
||||
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].label, "Root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_add_location() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let conn = FileTree::open_user_db(&user_id).unwrap();
|
||||
|
||||
let file_uuid = "abc123def456";
|
||||
let location = "/path/to/file.mp4";
|
||||
|
||||
FileTree::add_location(&conn, file_uuid, location, Some("origin")).unwrap();
|
||||
|
||||
let result: String = conn
|
||||
.query_row(
|
||||
"SELECT location FROM file_locations WHERE file_uuid = ?1",
|
||||
[file_uuid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, location);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_delete_all_nodes() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
for i in 1..=5 {
|
||||
let folder = FileTree::new_folder(&format!("Folder{}", i), None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
}
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 5);
|
||||
|
||||
for node in &loaded.nodes {
|
||||
conn.execute(
|
||||
"DELETE FROM file_nodes WHERE node_id = ?1",
|
||||
rusqlite::params![node.node_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let after_delete = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(after_delete.nodes.len(), 0);
|
||||
}
|
||||
130
tests/modes_test.rs
Normal file
130
tests/modes_test.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use markbase::filetree::node::NodeType;
|
||||
use markbase::filetree::{mode, FileTree};
|
||||
use rusqlite::Connection;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_db() -> (Connection, String) {
|
||||
let user_id = format!("test_{}", Uuid::new_v4());
|
||||
let conn = FileTree::init_user_db(&user_id).unwrap();
|
||||
(conn, user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_mode_render() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone()));
|
||||
let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child1).unwrap();
|
||||
tree.insert_node(&conn, &child2).unwrap();
|
||||
|
||||
let mode = mode::get_mode("tree").expect("tree mode should exist");
|
||||
let rendered = mode.render(&tree);
|
||||
|
||||
assert!(rendered.is_object());
|
||||
assert!(rendered["nodes"].is_array());
|
||||
assert_eq!(rendered["nodes"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_mode_render() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let mode = mode::get_mode("list").expect("list mode should exist");
|
||||
let rendered = mode.render(&tree);
|
||||
|
||||
assert!(rendered.is_object());
|
||||
assert!(rendered["nodes"].is_array());
|
||||
assert_eq!(rendered["nodes"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_sm_mode_render() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Images", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let mode = mode::get_mode("grid_sm").expect("grid_sm mode should exist");
|
||||
let rendered = mode.render(&tree);
|
||||
|
||||
assert!(rendered.is_object());
|
||||
assert!(rendered["nodes"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_lg_mode_render() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Documents", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let mode = mode::get_mode("grid_lg").expect("grid_lg mode should exist");
|
||||
let rendered = mode.render(&tree);
|
||||
|
||||
assert!(rendered.is_object());
|
||||
assert!(rendered["nodes"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_mode() {
|
||||
let mode = mode::get_mode("invalid_mode");
|
||||
assert!(mode.is_none(), "invalid mode should return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_name() {
|
||||
let tree_mode = mode::get_mode("tree").unwrap();
|
||||
assert_eq!(tree_mode.name(), "tree");
|
||||
|
||||
let list_mode = mode::get_mode("list").unwrap();
|
||||
assert_eq!(list_mode.name(), "list");
|
||||
|
||||
let grid_sm_mode = mode::get_mode("grid_sm").unwrap();
|
||||
assert_eq!(grid_sm_mode.name(), "grid_sm");
|
||||
|
||||
let grid_lg_mode = mode::get_mode("grid_lg").unwrap();
|
||||
assert_eq!(grid_lg_mode.name(), "grid_lg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_sort_options() {
|
||||
let mode = mode::get_mode("list").unwrap();
|
||||
let sort_options = mode.sort_options();
|
||||
|
||||
assert!(sort_options.len() > 0, "sort options should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_filter_options() {
|
||||
let mode = mode::get_mode("list").unwrap();
|
||||
let filter_options = mode.filter_options();
|
||||
|
||||
assert!(
|
||||
filter_options.len() > 0,
|
||||
"filter options should not be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_modes() {
|
||||
let modes = mode::list_modes();
|
||||
|
||||
assert_eq!(modes.len(), 4, "should have 4 display modes");
|
||||
|
||||
let names: Vec<&str> = modes.iter().map(|m| m.name()).collect();
|
||||
assert!(names.contains(&"tree"));
|
||||
assert!(names.contains(&"list"));
|
||||
assert!(names.contains(&"grid_sm"));
|
||||
assert!(names.contains(&"grid_lg"));
|
||||
}
|
||||
52
tests/render_test.rs
Normal file
52
tests/render_test.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use markbase::render::{md_to_html, page, render_page};
|
||||
|
||||
#[test]
|
||||
fn test_md_to_html_basic() {
|
||||
let md = "# Hello World\n\nThis is a test.";
|
||||
let html = md_to_html(md);
|
||||
|
||||
assert!(html.contains("<h1>"));
|
||||
assert!(html.contains("Hello World"));
|
||||
assert!(html.contains("<p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_md_to_html_table() {
|
||||
let md = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
|
||||
let html = md_to_html(md);
|
||||
|
||||
assert!(html.contains("<table>"));
|
||||
assert!(html.contains("<thead>"));
|
||||
assert!(html.contains("<tbody>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_md_to_html_tasklist() {
|
||||
let md = "- [x] Completed task\n- [ ] Pending task";
|
||||
let html = md_to_html(md);
|
||||
|
||||
assert!(html.contains("<li>"));
|
||||
assert!(html.contains("Completed task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_template() {
|
||||
let title = "Test Title";
|
||||
let content = "<p>Test content</p>";
|
||||
let html = page(title, content);
|
||||
|
||||
assert!(html.contains(title));
|
||||
assert!(html.contains(content));
|
||||
assert!(html.contains("<!DOCTYPE html>") || html.contains("<html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_page_mermaid() {
|
||||
let title = "Test";
|
||||
let content = "<code class=\"language-mermaid\">graph TD</code>";
|
||||
let html = render_page(title, content);
|
||||
|
||||
assert!(html.contains("<div class=\"mermaid\">"));
|
||||
assert!(!html.contains("<code class=\"language-mermaid\">"));
|
||||
assert!(html.contains("startOnLoad:true"));
|
||||
}
|
||||
Reference in New Issue
Block a user