11 Commits

Author SHA1 Message Date
tliu93 d39c1933b4 fix pytest ini
test / pytest (push) Successful in 38s
docker-image / build-and-push (push) Successful in 4m9s
2026-04-21 21:54:05 +02:00
tliu93 5aa87f60ad add ci/cd
test / pytest (push) Failing after 54s
2026-04-21 21:49:16 +02:00
tliu93 8fa3dace79 Remove macOS metadata files 2026-04-19 16:07:17 +02:00
tliu93 c3ba361724 Refine overview cards and ignore macOS files 2026-04-19 16:06:01 +02:00
tliu93 8d89caea0c fix image orientation 2026-04-19 14:47:18 +02:00
tliu93 f315614657 add support for heic images 2026-04-19 14:38:23 +02:00
tliu93 ef058765de add import script from notion 2026-04-19 14:28:00 +02:00
tliu93 bda23909bf ux refine 2026-04-19 14:06:31 +02:00
tliu93 e7a2719fa1 add temporary deploy 2026-04-19 13:33:43 +02:00
tliu93 314fc16b98 ux refine 2026-04-19 13:31:17 +02:00
tliu93 4c4ff61fab ux improve 2026-04-19 13:26:23 +02:00
28 changed files with 2066 additions and 189 deletions
Vendored
BIN
View File
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
.github
.env
.pytest_cache
.venv
__pycache__
*.pyc
*.db
backups
data
tests
+14
View File
@@ -0,0 +1,14 @@
# Runtime
HOST=0.0.0.0
PORT=10000
# In Docker, keep the database inside the mounted /app/data directory.
DATABASE_URL=sqlite:////app/data/app.db
# Host-side persistent data directory
DATA_DIR=./data
# Container user mapping
UID=1000
GID=1000
+60
View File
@@ -0,0 +1,60 @@
name: docker-image
on:
push:
tags:
- "v*"
env:
REGISTRY_HOST: code.wanderingbadger.dev
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify tag commit is on main
run: |
git fetch origin main --no-tags
TAG_COMMIT="${GITHUB_SHA}"
MAIN_COMMIT="$(git rev-parse origin/main)"
if ! git merge-base --is-ancestor "$TAG_COMMIT" "$MAIN_COMMIT"; then
echo "Tag ${GITHUB_REF_NAME} does not point to a commit reachable from origin/main"
exit 1
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
sbom: false
tags: |
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
+28
View File
@@ -0,0 +1,28 @@
name: test
on:
push:
branches:
- "**"
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run pytest
run: pytest
+10
View File
@@ -3,3 +3,13 @@ __pycache__/
.pytest_cache/
*.pyc
data/*.db
# macOS generated files
.DS_Store
**/.DS_Store
._*
**/._*
.Spotlight-V100
**/.Spotlight-V100
.Trashes
**/.Trashes
-1
View File
@@ -13,7 +13,6 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY tests ./tests
RUN mkdir -p /app/data
+351 -64
View File
@@ -10,7 +10,17 @@
- pytest / FastAPI TestClient
- Docker / Docker Compose
项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD、单图上传能力和全局搜索,但仍然没有加入 OCR、AI 识别或其他扩展功能
项目目标是小而稳、容易继续扩展。它不是企业平台,也不是复杂运维系统,重点是本地开发简单、容器部署稳定、数据持久化清楚、后续几个月后自己回来看也能快速接上
## 当前已支持
- 固定 3 级结构:`Box -> Item -> SubItem`
- Box / Item / SubItem 基础 CRUD
- Box / Item / SubItem 单图上传、替换、删除、展示
- Box / Item / SubItem 全局搜索
- Docker / Compose 长期运行
- SQLite 数据持久化
- 基础自动化测试
## 当前数据模型
@@ -36,30 +46,9 @@ Box
└── SubItem
```
## 当前已支持
目前已支持的基础能力:
- Box 列表、详情、新建、编辑、删除
- Item 新建、详情、编辑、删除
- SubItem 新建、编辑、删除
- Box / Item / SubItem 单张图片上传、替换、删除、展示
- Box / Item / SubItem 全局搜索
- `/` 重定向到 `/boxes`
- Jinja2 模板渲染
- 静态文件挂载
- SQLite 持久化
- Docker 长期运行
- 基础自动化测试
删除规则:
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
- 删除容器型 `Item` 时,会级联删除其下 `SubItem`
## 图片能力说明
这一阶段的图片系统保持简单直接:
图片系统保持简单直接:
- `Box` 最多支持 1 张图片
- `Item` 最多支持 1 张图片
@@ -67,10 +56,7 @@ Box
- 支持上传、替换、删除
- 不支持多图
图片主要用途是帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。
它不是一个原图归档系统。
### 图片处理方式
图片主要用帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。它不是原图归档系统。
上传图片后,系统会使用 Pillow 做统一处理:
@@ -87,7 +73,7 @@ Box
- `image_width`
- `image_height`
图片访问通过普通 HTTP 路由返回 JPEG 数据,例如:
图片访问路由例如:
- `/boxes/{id}/image`
- `/items/{id}/image`
@@ -95,10 +81,10 @@ Box
## 全局搜索
当前已经支持一个轻量的全局搜索页:
当前支持一个轻量的全局搜索页:
- 路由:`/search`
- 使用 query parameter,例如:`/search?q=电源线`
- 方式:`GET /search?q=关键词`
搜索范围包括:
@@ -109,13 +95,13 @@ Box
- `SubItem.name`
- `SubItem.note`
当前使用 SQLite 上的简单模糊匹配完成搜索,不引入外部搜索引擎或复杂全文系统。
当前使用 SQLite 上的简单模糊匹配,不引入外部搜索引擎或复杂全文系统。
搜索结果会尽量帮助你快速定位
搜索结果会显示
- 显示对象类型:`Box / Item / SubItem`
- 显示名称和备注
- 显示归属路径
- 对象类型:`Box / Item / SubItem`
- 名称和备注
- 归属路径
-`Item` 展示所属 `Box`
-`SubItem` 展示所属 `Item``Box`
- 如果对象已有图片,会显示一个小缩略图
@@ -124,7 +110,6 @@ Box
这一阶段仍然没有实现以下内容:
- 搜索
- 多图上传
- OCR
- AI 识别物品
@@ -149,14 +134,13 @@ Box
│ ├── static
│ │ └── style.css
│ └── templates
│ ├── base.html
│ ├── boxes
│ ├── items
│ └── subitems
├── data
├── scripts
│ ├── backup_db.sh
│ └── deploy.sh
├── tests
│ ├── conftest.py
│ └── test_app.py
├── .dockerignore
├── .env.example
├── docker-compose.yml
├── Dockerfile
├── pytest.ini
@@ -166,17 +150,38 @@ Box
## 轻量配置
项目通过环境变量支持以下配置
项目通过环境变量支持以下部署时真正需要关心的配置:
- `DATABASE_URL`
- `HOST`
- `PORT`
- `DATABASE_URL`
- `DATA_DIR`
- `UID`
- `GID`
默认值
推荐从示例文件开始
- `DATABASE_URL=sqlite:///./data/app.db`
- `HOST=0.0.0.0`
- `PORT=10000`
```bash
cp .env.example .env
```
默认值如下:
```env
HOST=0.0.0.0
PORT=10000
DATABASE_URL=sqlite:////app/data/app.db
DATA_DIR=./data
UID=1000
GID=1000
```
说明:
- 本地开发默认数据库仍然是 `./data/app.db`
- Docker 内建议继续使用 `sqlite:////app/data/app.db`
- `DATA_DIR` 控制宿主机上的持久化目录
- `UID/GID` 用来让容器内文件权限更贴近宿主机用户
## 本地开发模式
@@ -213,14 +218,33 @@ http://localhost:10000
./data/app.db
```
## Docker 部署模
## Docker 运行方
Docker / Compose 是这个项目面向长期运行环境的方式。
启动:
### 首次准备
```bash
docker compose up --build
cp .env.example .env
mkdir -p data
```
### 启动 / 更新
```bash
docker compose up -d --build
```
### 查看状态
```bash
docker compose ps
```
### 查看日志
```bash
docker compose logs -f web
```
访问:
@@ -229,20 +253,129 @@ docker compose up --build
http://localhost:10000
```
说明:
### Compose 配置说明
当前 `docker-compose.yml` 保持尽量简单:
- 默认暴露 `10000` 端口
- `restart: unless-stopped`
- 容器使用 `1000:1000` 运行
- SQLite 文件持久化到宿主机 `./data/app.db`
- 容器重建不会丢失数据
- 容器用户来自 `UID:GID`
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
- SQLite 默认写入 `/app/data/app.db`
备份时直接复制 SQLite 文件即可:
## 自动化部署
这个项目没有复杂 CI/CD,只提供一个适合家用项目的轻量部署脚本:
```bash
./scripts/deploy.sh
```
它会按顺序执行:
1. 检查 `.env`
2. 准备数据目录
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
4. 执行 `docker compose up -d --build`
5. 输出容器状态
6. 输出最近日志
这个脚本的目标不是做平台化发布,而是让“更新代码并刷新容器”变成一个稳定、可重复执行的动作。
如果你不想自动拉代码,也可以直接手动运行:
```bash
docker compose up -d --build
```
## 数据持久化
当前 SQLite 文件默认会保存在:
- 宿主机:`./data/app.db`
- 容器内:`/app/data/app.db`
这是因为 `docker-compose.yml` 把:
```text
./data/app.db
${DATA_DIR:-./data}
```
挂载到了容器内:
```text
/app/data
```
因此:
- 容器重建不会删除宿主机上的数据库文件
- 更新镜像不会导致 SQLite 数据丢失
- 只要保留 `DATA_DIR` 目录,数据就还在
## 备份与恢复
### 最简单的备份方式
直接复制 SQLite 文件即可:
```bash
cp data/app.db backups/app.db
```
或者使用附带脚本:
```bash
./scripts/backup_db.sh
```
它会在 `backups/` 目录下生成一个带时间戳的副本。
### 最简单的恢复方式
停止容器后,把备份文件覆盖回去:
```bash
docker compose stop
cp backups/app-YYYYMMDD-HHMMSS.db data/app.db
docker compose up -d
```
## 常见排查
### 1. 查看容器日志
```bash
docker compose logs -f web
```
### 2. 确认服务是否已启动
```bash
docker compose ps
```
如果状态是 `Up`,通常说明容器已经跑起来了。
### 3. 确认端口映射
```bash
docker compose port web 10000
```
### 4. 确认数据库文件还在
```bash
ls -lh data
```
如果看到 `app.db`,说明宿主机持久化文件还在。
### 5. 容器更新后数据为什么没丢
因为数据库不放在镜像里,而是放在宿主机挂载目录 `DATA_DIR` 中。
镜像更新只会替换应用代码和运行环境,不会覆盖这个宿主机目录。
## 测试
运行测试:
@@ -256,10 +389,164 @@ python -m pytest
当前测试覆盖包括:
- Box / Item / SubItem 基础 CRUD
- 404 返回
- 非容器 Item 不能创建 SubItem
- Box / Item 删除后的级联删除
- 图片上传、转换为 JPEG、缩放、读取、替换、删除
- 全局搜索 name / note,并展示对象类型与归属路径
- 无图片访问和非法图片上传等错误路径
- 关键 POST 请求后的重定向行为
- 图片上传、替换、删除与错误路径
- 全局搜索 name / note
- 创建后的重定向行为
- 关键页面结构和 UX 文案
## CI / CD
仓库现在包含两条基础自动化流程,文件位于:
- `.github/workflows/test.yml`
- `.github/workflows/docker-image.yml`
### CIbranch push 自动跑 pytest
`test.yml` 会在任意 branch 的 `push` 上执行:
1. checkout 代码
2. 使用 Python `3.12`
3. 安装 `requirements.txt`
4. 运行 `pytest`
当前测试不依赖外部数据库服务。
测试使用 `tmp_path` 创建独立 SQLite 文件,并通过 `configure_database(...)` 切换到临时数据库,因此:
- 不会污染 `./data/app.db`
- 不要求额外启动 Docker 或 Compose
- 不要求额外配置测试环境变量
### CDtag 发布 Docker image
`docker-image.yml` 会在推送符合 `v*` 格式的 tag 时触发,例如:
- `v1.0.0`
- `v1.2.3`
workflow 会先校验该 tag 指向的提交是否可从 `origin/main` 到达;只有满足这个条件的 tag 才会继续构建并推送镜像。
镜像发布目标:
- Registry Host: `code.wanderingbadger.dev`
- Image Name: `${{ github.repository }}`
- Platforms:
- `linux/amd64`
- `linux/arm64`
推送的 tag 策略:
- `${tag}`
- `latest`
例如仓库名为 `tliu93/2026-moving-helper`,打出 `v1.0.0` 后会推送:
```text
code.wanderingbadger.dev/tliu93/2026-moving-helper:v1.0.0
code.wanderingbadger.dev/tliu93/2026-moving-helper:latest
```
### Actions / Gitea Secrets
需要在仓库的 Actions secrets 中配置:
- `REGISTRY_USERNAME`
- `REGISTRY_TOKEN`
推荐含义:
- `REGISTRY_USERNAME`: Gitea 用户名
- `REGISTRY_TOKEN`: 具备 Container Registry 推送权限的 Access Token
如果你的 Gitea 实例对 package / registry 权限做了单独控制,确保这个 token 至少具备对应仓库的镜像推送权限。
### 如何触发镜像发布
建议流程:
```bash
git checkout main
git pull --ff-only origin main
git tag v1.0.0
git push origin main --tags
```
如果只想推送单个 tag
```bash
git push origin v1.0.0
```
### 本地手动构建镜像
单架构本地构建:
```bash
docker build -t moving-helper:local .
```
本地运行:
```bash
docker run --rm -p 10000:10000 \
-e DATABASE_URL=sqlite:////app/data/app.db \
moving-helper:local
```
如果要模拟发布时的多架构构建,可以使用 buildx:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t code.wanderingbadger.dev/${USER}/2026-moving-helper:test \
--load \
.
```
## 一次性 Notion 导入
项目内附带了一个一次性迁移脚本:
```bash
python scripts/import_notion.py --dry-run
python scripts/import_notion.py --apply
```
说明:
- 这是一次性 migration / import 工具,不是长期同步功能
- 运行时会交互要求输入:
- Notion API token
- Notion 页面完整 URL
- `--dry-run` 只读取和解析,不写数据库
- `--apply` 会真正写入当前 SQLite 数据库
- 建议导入前先备份 `data/app.db`
### 当前支持的 Notion 结构映射
- `heading_2` -> `Box`
- 某个 `heading_2` 下的一级 bullet -> `Item`
- 如果一级 bullet 下还有二级 bullet
- 一级 bullet -> 容器型 `Item`
- 二级 bullet -> `SubItem`
当前最大只处理到这个层级:
```text
heading_2
└── 一级 bullet
└── 二级 bullet
```
更深层级会在日志中提示,但不会继续扩展成无限树。
### 这一版不导入图片
这一版导入脚本:
- 不下载图片
- 不导入图片
- 遇到图片或其他媒体 block 时会提示已跳过
图片后续可以在应用里手动补录。
+52 -2
View File
@@ -1,8 +1,20 @@
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
import shutil
import subprocess
from tempfile import TemporaryDirectory
from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageOps, UnidentifiedImageError
try:
from pillow_heif import register_heif_opener
register_heif_opener()
HEIF_SUPPORT_ENABLED = True
except ImportError:
HEIF_SUPPORT_ENABLED = False
MAX_IMAGE_SIDE = 1600
@@ -22,6 +34,8 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
if file is None or not file.filename:
return None
suffix = Path(file.filename).suffix.lower()
try:
raw_bytes = file.file.read()
if not raw_bytes:
@@ -30,6 +44,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
with Image.open(BytesIO(raw_bytes)) as source_image:
processed_image = _prepare_image(source_image)
except UnidentifiedImageError as exc:
if suffix in {".heic", ".heif"}:
processed_image = _process_heic_with_fallback(raw_bytes, suffix)
return processed_image
raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
except HTTPException:
raise
@@ -42,7 +59,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
def _prepare_image(source_image: Image.Image) -> ProcessedImage:
prepared = _strip_metadata_and_convert(source_image)
# Normalize orientation from EXIF before resizing or stripping metadata.
prepared = ImageOps.exif_transpose(source_image)
prepared = _strip_metadata_and_convert(prepared)
prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
output = BytesIO()
@@ -71,3 +90,34 @@ def _strip_metadata_and_convert(source_image: Image.Image) -> Image.Image:
return source_image.convert("RGB")
return source_image.copy()
def _process_heic_with_fallback(raw_bytes: bytes, suffix: str) -> ProcessedImage:
if shutil.which("sips") is None:
if HEIF_SUPPORT_ENABLED:
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片")
raise HTTPException(
status_code=400,
detail="当前环境无法处理 HEIC/HEIF,请先转换为 JPG 或 PNG 后再上传",
)
with TemporaryDirectory() as temp_dir:
source_path = Path(temp_dir) / f"upload{suffix}"
output_path = Path(temp_dir) / "converted.jpg"
source_path.write_bytes(raw_bytes)
result = subprocess.run(
["sips", "-s", "format", "jpeg", str(source_path), "--out", str(output_path)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0 or not output_path.exists():
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片")
try:
with Image.open(output_path) as converted_image:
return _prepare_image(converted_image)
except UnidentifiedImageError as exc:
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片") from exc
+18 -2
View File
@@ -82,6 +82,10 @@ def _image_response_or_404(target) -> Response:
return Response(content=target.image_blob, media_type=target.image_mime_type)
def _wants_add_next(submit_action: str | None) -> bool:
return submit_action == "save_and_add_next"
def _build_search_results(db: Session, query: str) -> list[dict]:
keyword = f"%{query.lower()}%"
results: list[dict] = []
@@ -333,6 +337,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
submit_action: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
@@ -348,7 +353,13 @@ def create_app() -> FastAPI:
db.add(item)
db.commit()
db.refresh(item)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
if _wants_add_next(submit_action):
redirect_url = f"/boxes/{box.id}/items/new"
elif item.is_container:
redirect_url = f"/items/{item.id}"
else:
redirect_url = f"/boxes/{box.id}"
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/items/{item_id}")
def show_item(item_id: int, request: Request, db: Session = Depends(get_db)):
@@ -436,6 +447,7 @@ def create_app() -> FastAPI:
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
submit_action: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
@@ -451,7 +463,11 @@ def create_app() -> FastAPI:
db.add(subitem)
db.commit()
db.refresh(subitem)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
if _wants_add_next(submit_action):
redirect_url = f"/items/{item.id}/subitems/new"
else:
redirect_url = f"/items/{item.id}"
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/subitems/{subitem_id}/image")
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlparse
import requests
from requests import Response
from sqlalchemy.orm import Session
from app.db import init_db
from app.models import Box, Item, SubItem
NOTION_VERSION = "2026-03-11"
NOTION_API_BASE = "https://api.notion.com/v1"
@dataclass(slots=True)
class ParsedSubItem:
name: str
note: str | None = None
@dataclass(slots=True)
class ParsedItem:
name: str
note: str | None = None
is_container: bool = False
subitems: list[ParsedSubItem] = field(default_factory=list)
@dataclass(slots=True)
class ParsedBox:
name: str
note: str | None = None
items: list[ParsedItem] = field(default_factory=list)
@dataclass(slots=True)
class ImportSummary:
boxes: list[ParsedBox]
warnings: list[str] = field(default_factory=list)
@property
def box_count(self) -> int:
return len(self.boxes)
@property
def item_count(self) -> int:
return sum(len(box.items) for box in self.boxes)
@property
def container_item_count(self) -> int:
return sum(1 for box in self.boxes for item in box.items if item.is_container)
@property
def subitem_count(self) -> int:
return sum(len(item.subitems) for box in self.boxes for item in box.items)
class NotionClient:
def __init__(self, token: str):
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {token}",
"Notion-Version": NOTION_VERSION,
}
)
def list_block_children(self, block_id: str) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
next_cursor: str | None = None
while True:
params = {"page_size": 100}
if next_cursor:
params["start_cursor"] = next_cursor
response = self.session.get(
f"{NOTION_API_BASE}/blocks/{block_id}/children",
params=params,
timeout=30,
)
self._raise_for_status(response)
payload = response.json()
results.extend(payload.get("results", []))
if not payload.get("has_more"):
break
next_cursor = payload.get("next_cursor")
return results
def _raise_for_status(self, response: Response) -> None:
try:
response.raise_for_status()
except requests.HTTPError as exc:
message = response.text
raise RuntimeError(f"Notion API 请求失败: {response.status_code} {message}") from exc
def extract_page_id(page_url: str) -> str:
cleaned = page_url.strip()
parsed = urlparse(cleaned)
candidates = [segment for segment in parsed.path.split("/") if segment]
if parsed.fragment:
candidates.append(parsed.fragment)
matches: list[str] = []
pattern = re.compile(
r"([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"
)
for candidate in candidates:
matches.extend(pattern.findall(candidate))
if not matches:
raise ValueError("无法从 Notion 页面 URL 中提取 page id")
raw = matches[-1].replace("-", "").lower()
return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
def fetch_page_blocks(token: str, page_id: str) -> list[dict[str, Any]]:
client = NotionClient(token)
return _fetch_block_tree(client, page_id)
def _fetch_block_tree(client: NotionClient, block_id: str) -> list[dict[str, Any]]:
blocks = client.list_block_children(block_id)
for block in blocks:
if block.get("has_children"):
block["_children"] = _fetch_block_tree(client, block["id"])
else:
block["_children"] = []
return blocks
def parse_notion_blocks(blocks: list[dict[str, Any]]) -> ImportSummary:
boxes: list[ParsedBox] = []
warnings: list[str] = []
current_box: ParsedBox | None = None
for block in blocks:
block_type = block.get("type")
if block_type == "heading_2":
heading_text = extract_block_text(block)
if not heading_text:
warnings.append("发现空的 heading_2,已跳过")
continue
current_box = ParsedBox(name=heading_text)
boxes.append(current_box)
continue
if block_type == "bulleted_list_item":
if current_box is None:
warnings.append(
f"发现未归属到任何 heading_2 的一级 bullet{extract_block_text(block) or '[空文本]'}"
)
continue
parsed_item = _parse_item_block(block, warnings, level=1)
if parsed_item is not None:
current_box.items.append(parsed_item)
continue
warnings.extend(_warning_for_unsupported_block(block, level=0))
return ImportSummary(boxes=boxes, warnings=warnings)
def _parse_item_block(
block: dict[str, Any],
warnings: list[str],
*,
level: int,
) -> ParsedItem | None:
item_name = extract_block_text(block)
if not item_name:
warnings.append(f"发现空的 bullet(层级 {level}),已跳过")
return None
child_blocks = block.get("_children", [])
subitems: list[ParsedSubItem] = []
for child in child_blocks:
child_type = child.get("type")
if child_type == "bulleted_list_item":
child_name = extract_block_text(child)
if not child_name:
warnings.append(f"发现空的二级 bullet(父项:{item_name}),已跳过")
continue
subitems.append(ParsedSubItem(name=child_name))
if child.get("_children"):
warnings.append(
f"发现超出支持层级的三级内容(父项:{item_name} -> 子项:{child_name}),已忽略更深层级"
)
for deep_child in child["_children"]:
warnings.extend(_warning_for_unsupported_block(deep_child, level=3))
continue
warnings.extend(_warning_for_unsupported_block(child, level=2, parent_name=item_name))
return ParsedItem(
name=item_name,
is_container=bool(subitems),
subitems=subitems,
)
def _warning_for_unsupported_block(
block: dict[str, Any],
*,
level: int,
parent_name: str | None = None,
) -> list[str]:
block_type = block.get("type", "unknown")
text = extract_block_text(block) or "[无文本]"
prefix = f"层级 {level} block"
if parent_name:
prefix += f"(父项:{parent_name}"
if block_type in {"image", "file", "video", "audio", "pdf"}:
return [f"{prefix} 类型 {block_type} 已跳过(这版不导入图片或媒体):{text}"]
return [f"{prefix} 类型 {block_type} 未按导入规则处理,已跳过:{text}"]
def extract_block_text(block: dict[str, Any]) -> str:
block_type = block.get("type")
block_data = block.get(block_type, {}) if block_type else {}
rich_text = block_data.get("rich_text", [])
return "".join(part.get("plain_text", "") for part in rich_text).strip()
def print_summary(summary: ImportSummary) -> None:
print()
print("解析结果摘要")
print(f"- Box: {summary.box_count}")
print(f"- Item: {summary.item_count}")
print(f"- 其中容器型 Item: {summary.container_item_count}")
print(f"- SubItem: {summary.subitem_count}")
print(f"- Warnings: {len(summary.warnings)}")
print()
for box in summary.boxes:
container_names = [item.name for item in box.items if item.is_container]
print(f"[Box] {box.name}")
print(f" - Item 数量: {len(box.items)}")
if container_names:
print(f" - 容器型 Item: {', '.join(container_names)}")
for item in box.items:
if item.is_container:
print(f" * {item.name} -> SubItem {len(item.subitems)}")
if summary.warnings:
print()
print("Warnings")
for warning in summary.warnings:
print(f"- {warning}")
def apply_import(summary: ImportSummary, db: Session) -> dict[str, int]:
init_db()
created_boxes = 0
created_items = 0
created_subitems = 0
for parsed_box in summary.boxes:
box = Box(name=parsed_box.name, note=parsed_box.note)
db.add(box)
db.flush()
created_boxes += 1
for parsed_item in parsed_box.items:
item = Item(
box=box,
name=parsed_item.name,
note=parsed_item.note,
quantity=1,
is_container=parsed_item.is_container,
)
db.add(item)
db.flush()
created_items += 1
for parsed_subitem in parsed_item.subitems:
subitem = SubItem(
parent_item=item,
name=parsed_subitem.name,
note=parsed_subitem.note,
quantity=1,
)
db.add(subitem)
created_subitems += 1
db.commit()
return {
"boxes": created_boxes,
"items": created_items,
"subitems": created_subitems,
}
+301 -18
View File
@@ -7,9 +7,9 @@ body {
}
.container {
max-width: 840px;
margin: 48px auto;
padding: 24px;
max-width: 1100px;
margin: 28px auto;
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
@@ -17,6 +17,8 @@ body {
h1 {
margin-top: 0;
margin-bottom: 6px;
font-size: 1.9rem;
}
h2,
@@ -59,18 +61,45 @@ button,
border-radius: 8px;
cursor: pointer;
padding: 10px 14px;
text-decoration: none;
line-height: 1.2;
}
.button-primary {
background: #0b57d0;
color: #fff;
}
.button-secondary {
background: #eef3f8;
color: #1f2937;
border: 1px solid #cbd5e1;
}
.button-danger {
background: #b42318;
color: #fff;
}
.button-small {
padding: 8px 12px;
font-size: 0.92rem;
}
.button:hover,
button:hover {
opacity: 0.92;
button:hover,
.button:focus-visible,
button:focus-visible {
opacity: 0.94;
text-decoration: none;
}
.top-nav {
margin-bottom: 24px;
margin-bottom: 18px;
display: flex;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.page-header,
@@ -82,18 +111,63 @@ button:hover {
flex-wrap: wrap;
}
.page-header {
margin-bottom: 10px;
}
.stack {
display: grid;
gap: 16px;
gap: 12px;
}
.card {
border: 1px solid #ddd;
border-radius: 10px;
padding: 16px;
padding: 14px;
background: #fafafa;
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
color: #666;
font-size: 0.95rem;
}
.type-tag {
display: inline-block;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
margin-bottom: 8px;
}
.type-box {
background: #e6f0ff;
color: #0b57d0;
}
.type-item {
background: #eef7e8;
color: #2f6b1f;
}
.type-container {
background: #fff1da;
color: #9a4d00;
}
.type-subitem {
background: #f2ebff;
color: #6f42c1;
}
.search-form {
display: flex;
gap: 12px;
@@ -127,16 +201,147 @@ button:hover {
margin-bottom: 16px;
}
.detail-image-compact {
max-width: 180px;
margin-bottom: 8px;
}
.thumb-image {
display: block;
width: 120px;
width: 64px;
max-width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 12px;
margin-bottom: 0;
border: 1px solid #ddd;
}
.compact-thumb {
flex: 0 0 64px;
}
.dense-list {
display: grid;
gap: 8px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.compact-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: start;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 10px;
background: #fafafa;
position: relative;
}
.compact-row-box {
grid-template-columns: 1fr auto;
}
.overview-card {
grid-template-columns: 1fr;
gap: 8px;
align-content: start;
min-height: 150px;
}
.overview-card-box {
min-height: 140px;
}
.compact-row-container {
border-left: 4px solid #d98700;
}
.compact-row-item {
border-left: 4px solid #3d7a2a;
}
.compact-row-subitem {
border-left: 4px solid #7b57c2;
}
.compact-main h2,
.compact-main h3 {
margin-bottom: 4px;
font-size: 1rem;
}
.overview-card .compact-main h2,
.overview-card .compact-main h3 {
font-size: 1.02rem;
}
.row-title-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.row-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 2px 10px;
color: #555;
font-size: 0.9rem;
}
.overview-card .row-meta-grid {
grid-template-columns: 1fr;
gap: 2px;
}
.row-note {
margin-top: 4px;
margin-bottom: 0;
color: #333;
font-size: 0.9rem;
}
.clickable-card {
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.clickable-card:hover,
.clickable-card:focus-within {
border-color: #9fb8e8;
box-shadow: 0 0 0 3px rgba(11, 87, 208, 0.08);
background: #fcfdff;
}
.detail-card-compact {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px 16px;
align-items: start;
}
.detail-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 4px 12px;
}
.detail-note {
grid-column: 1 / -1;
}
.section-heading h2 {
margin-bottom: 2px;
}
.meta,
.muted {
color: #666;
@@ -153,18 +358,96 @@ button:hover {
margin: 0;
}
.checkbox-help {
margin-top: -4px;
color: #666;
font-size: 0.92rem;
}
.form-panel {
gap: 14px;
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.form-field {
margin: 0;
}
.context-panel {
border: 1px solid #d9e2f2;
border-radius: 10px;
background: #f5f8fd;
padding: 12px;
}
.context-title {
font-weight: 700;
margin-bottom: 8px;
}
.context-body {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.context-body:last-child {
margin-bottom: 0;
}
.actions form {
margin: 0;
}
.link-button {
background: none;
border: none;
color: #b42318;
padding: 0;
cursor: pointer;
@media (max-width: 720px) {
.container {
margin: 0;
border-radius: 0;
padding: 16px;
}
.compact-row,
.compact-row-box {
grid-template-columns: 1fr;
}
.row-actions {
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
}
.thumb-image,
.compact-thumb {
width: 72px;
}
.detail-card-compact {
grid-template-columns: 1fr;
}
}
.link-button:hover {
text-decoration: underline;
@media (min-width: 900px) {
.overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.overview-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1600px) {
.overview-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
+41
View File
@@ -14,5 +14,46 @@
</nav>
{% block content %}{% endblock %}
</main>
<script>
document.addEventListener("click", function (event) {
const card = event.target.closest(".clickable-card[data-href]");
if (!card) return;
if (event.target.closest("a, button, form, input, textarea, select, label")) return;
window.location.href = card.dataset.href;
});
document.addEventListener("keydown", function (event) {
const card = event.target.closest(".clickable-card[data-href]");
if (!card) return;
if (event.key !== "Enter" && event.key !== " ") return;
if (event.target.closest("a, button, form, input, textarea, select, label")) return;
event.preventDefault();
window.location.href = card.dataset.href;
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
if (event.target.tagName === "TEXTAREA") return;
if (event.target.type === "submit") return;
if (!event.target.closest("form")) return;
const focusable = Array.from(
event.target.form.querySelectorAll(
'input:not([type="hidden"]):not([type="submit"]):not([type="checkbox"]), textarea, select'
)
).filter((element) => !element.disabled);
const index = focusable.indexOf(event.target);
if (index === -1 || index === focusable.length - 1) return;
event.preventDefault();
focusable[index + 1].focus();
if (focusable[index + 1].select) {
focusable[index + 1].select();
}
});
</script>
</body>
</html>
+35 -11
View File
@@ -1,29 +1,51 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/boxes">箱子</a>
<span>/</span>
<strong>{{ "新建 Box" if not box else "编辑 Box" }}</strong>
</div>
<div class="page-header">
<h1>{{ page_title }}</h1>
<a href="/boxes">返回箱子列表</a>
<div>
<div class="type-tag type-box">Box</div>
<h1>{{ page_title }}</h1>
<p class="muted">
{% if box %}
你当前正在编辑一个顶层箱子。
{% else %}
你当前正在创建一个新的顶层箱子。
{% endif %}
</p>
</div>
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
</div>
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
<section class="context-panel">
<div class="context-title">当前操作</div>
<div class="context-body">
<span class="type-tag type-box">Box</span>
<span>{{ "创建顶层箱子" if not box else "编辑顶层箱子" }}</span>
</div>
</section>
<label class="form-field">
名称
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
<input type="text" name="name" value="{{ box.name if box else '' }}" required autofocus>
</label>
<label>
<label class="form-field">
房间
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
</label>
<label>
<label class="form-field">
状态
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
</label>
<label>
<label class="form-field">
备注
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
</label>
<label>
<label class="form-field">
图片
<input type="file" name="image_file" accept="image/*">
</label>
@@ -33,7 +55,7 @@
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
<button
type="submit"
class="link-button"
class="button button-danger button-small"
formaction="/boxes/{{ box.id }}/image/delete"
formmethod="post"
>
@@ -41,6 +63,8 @@
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
<div class="form-actions">
<button type="submit" class="button button-primary">{{ submit_label }}</button>
</div>
</form>
{% endblock %}
+26 -13
View File
@@ -1,26 +1,39 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<span>首页</span>
<span>/</span>
<strong>箱子</strong>
</div>
<div class="page-header">
<div>
<h1>箱子</h1>
<div class="type-tag type-box">Box</div>
<h1>箱子总览</h1>
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
</div>
<a class="button" href="/boxes/new">新建箱子</a>
<a class="button button-primary" href="/boxes/new">新建箱子</a>
</div>
{% if boxes %}
<div class="stack">
<div class="overview-grid">
{% for box in boxes %}
<section class="card">
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
<p class="meta">物品数:{{ box.items|length }}</p>
{% if box.room %}<p>房间:{{ box.room }}</p>{% endif %}
{% if box.status %}<p>状态:{{ box.status }}</p>{% endif %}
{% if box.note %}<p>{{ box.note }}</p>{% endif %}
<div class="actions">
<a href="/boxes/{{ box.id }}">查看详情</a>
<a href="/boxes/{{ box.id }}/edit">编辑</a>
<section
class="compact-row compact-row-box clickable-card overview-card overview-card-box"
data-href="/boxes/{{ box.id }}"
tabindex="0"
role="link"
aria-label="查看箱子 {{ box.name }}"
>
<div class="compact-main">
<div class="row-title-line">
<span class="type-tag type-box">Box</span>
<h2>{{ box.name }}</h2>
</div>
<div class="row-meta-grid">
<span>物品数:{{ box.items|length }}</span>
</div>
{% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %}
</div>
</section>
{% endfor %}
@@ -28,7 +41,7 @@
{% else %}
<section class="card">
<p>还没有箱子。</p>
<a href="/boxes/new">创建第一个箱子</a>
<a class="button button-primary" href="/boxes/new">创建第一个箱子</a>
</section>
{% endif %}
{% endblock %}
+43 -26
View File
@@ -1,57 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/boxes">箱子</a>
<span>/</span>
<strong>{{ box.name }}</strong>
</div>
<div class="page-header">
<div>
<div class="type-tag type-box">Box</div>
<h1>{{ box.name }}</h1>
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
</div>
<div class="actions">
<a href="/boxes">返回箱子列表</a>
<a href="/search">去搜索</a>
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
<a class="button button-secondary button-small" href="/search">去搜索</a>
<a class="button button-primary" href="/boxes/{{ box.id }}/items/new">添加物品</a>
</div>
</div>
<section class="card">
<section class="card detail-card detail-card-compact">
{% if box.image_blob %}
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image detail-image-compact">
{% endif %}
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
<p><strong>状态</strong> {{ box.status or '-' }}</p>
<p><strong>备注</strong> {{ box.note or '-' }}</p>
<div class="detail-meta-grid">
<p><strong>房间</strong> {{ box.room or '-' }}</p>
<p><strong>状态</strong> {{ box.status or '-' }}</p>
<p class="detail-note"><strong>备注:</strong> {{ box.note or '-' }}</p>
</div>
<div class="actions">
<a href="/boxes/{{ box.id }}/edit">编辑箱子</a>
<a class="button button-secondary button-small" href="/boxes/{{ box.id }}/edit">编辑箱子</a>
<form method="post" action="/boxes/{{ box.id }}/delete">
<button type="submit" class="link-button">删除箱子</button>
<button type="submit" class="button button-danger button-small">删除箱子</button>
</form>
</div>
</section>
<section class="stack">
<h2>物品</h2>
<div class="section-heading">
<h2>内部物品</h2>
<p class="muted">重点浏览区域,点击任意一行可进入物品详情。</p>
</div>
{% if box.items %}
<div class="overview-grid">
{% for item in box.items %}
<article class="card">
<article
class="compact-row clickable-card overview-card {{ 'compact-row-container' if item.is_container else 'compact-row-item' }}"
data-href="/items/{{ item.id }}"
tabindex="0"
role="link"
aria-label="查看物品 {{ item.name }}"
>
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image">
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb">
{% endif %}
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
{% if item.note %}<p><strong>备注:</strong> {{ item.note }}</p>{% endif %}
<div class="actions">
<a href="/items/{{ item.id }}">查看详情</a>
<a href="/items/{{ item.id }}/edit">编辑</a>
{% if item.is_container %}
<a href="/items/{{ item.id }}">查看内部内容</a>
{% endif %}
<form method="post" action="/items/{{ item.id }}/delete">
<button type="submit" class="link-button">删除</button>
</form>
<div class="compact-main">
<div class="row-title-line">
<span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
{{ "容器型 Item" if item.is_container else "Item" }}
</span>
<h3>{{ item.name }}</h3>
</div>
<div class="row-meta-grid">
<span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span>
</div>
{% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<section class="card">
<p>这个箱子里还没有物品。</p>
+50 -10
View File
@@ -1,32 +1,65 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/boxes">箱子</a>
<span>/</span>
<a href="/boxes/{{ box.id }}">{{ box.name }}</a>
<span>/</span>
<strong>{{ "新建 Item" if not item else "编辑 Item" }}</strong>
</div>
<div class="page-header">
<div>
<div class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
{{ "容器型 Item" if item and item.is_container else "Item" }}
</div>
<h1>{{ page_title }}</h1>
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
<p class="muted">
{% if item %}
你当前正在编辑这个物品,并可决定它是否是一个小容器。
{% else %}
你当前正在往这个箱子里添加一个 Item,可选择它是普通物品还是小容器。
{% endif %}
</p>
</div>
<a href="/boxes/{{ box.id }}">返回箱子</a>
<a class="button button-secondary button-small" href="/boxes/{{ box.id }}">返回箱子</a>
</div>
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
<section class="context-panel">
<div class="context-title">当前上下文</div>
<div class="context-body">
<span class="type-tag type-box">Box</span>
<span>{{ box.name }}</span>
</div>
<div class="context-body">
<span class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
{{ "容器型 Item" if item and item.is_container else "Item" }}
</span>
<span>{{ "创建新的二级物品" if not item else "编辑当前二级物品" }}</span>
</div>
</section>
<label class="form-field">
名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
</label>
<label>
<label class="form-field">
数量
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '' }}">
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '1' }}">
</label>
<label class="checkbox-row">
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
这个物品本身是一个小容器
</label>
<label>
<div class="checkbox-help">
勾选后,这个 Item 将作为“第二层容器”,后续可以继续往里面添加最后一级的 SubItem。
</div>
<label class="form-field">
备注
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
</label>
<label>
<label class="form-field">
图片
<input type="file" name="image_file" accept="image/*">
</label>
@@ -36,7 +69,7 @@
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
<button
type="submit"
class="link-button"
class="button button-danger button-small"
formaction="/items/{{ item.id }}/image/delete"
formmethod="post"
>
@@ -44,6 +77,13 @@
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
<div class="form-actions">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not item %}
<button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
保存并添加下一个
</button>
{% endif %}
</div>
</form>
{% endblock %}
+45 -21
View File
@@ -1,28 +1,39 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/boxes">箱子</a>
<span>/</span>
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
<span>/</span>
<strong>{{ item.name }}</strong>
</div>
<div class="page-header">
<div>
<div class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
{{ "容器型 Item" if item.is_container else "Item" }}
</div>
<h1>{{ item.name }}</h1>
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a></p>
</div>
<div class="actions">
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
<a href="/search">去搜索</a>
<a href="/items/{{ item.id }}/edit">编辑物品</a>
<a class="button button-secondary button-small" href="/boxes/{{ item.box.id }}">返回箱子</a>
<a class="button button-secondary button-small" href="/search">去搜索</a>
<a class="button button-secondary button-small" href="/items/{{ item.id }}/edit">编辑物品</a>
</div>
</div>
<section class="card">
<section class="card detail-card detail-card-compact">
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image detail-image-compact">
{% endif %}
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
<p><strong>备注:</strong> {{ item.note or '-' }}</p>
<div class="detail-meta-grid">
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
<p class="detail-note"><strong>备注:</strong> {{ item.note or '-' }}</p>
</div>
<div class="actions">
<form method="post" action="/items/{{ item.id }}/delete">
<button type="submit" class="link-button">删除物品</button>
<button type="submit" class="button button-danger button-small">删除物品</button>
</form>
</div>
</section>
@@ -30,26 +41,39 @@
{% if item.is_container %}
<section class="stack">
<div class="page-header">
<h2>子物品</h2>
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
<div class="section-heading">
<h2>内部子物品</h2>
<p class="muted">当前容器里装的内容,点击任意一行可进入对应编辑上下文。</p>
</div>
<a class="button button-primary" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
</div>
{% if item.subitems %}
<div class="overview-grid">
{% for subitem in item.subitems %}
<article class="card">
<article
class="compact-row clickable-card compact-row-subitem overview-card"
data-href="/subitems/{{ subitem.id }}/edit"
tabindex="0"
role="link"
aria-label="查看子物品 {{ subitem.name }}"
>
{% if subitem.image_blob %}
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb">
{% endif %}
<h3>{{ subitem.name }}</h3>
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
<div class="actions">
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
<form method="post" action="/subitems/{{ subitem.id }}/delete">
<button type="submit" class="link-button">删除</button>
</form>
<div class="compact-main">
<div class="row-title-line">
<span class="type-tag type-subitem">SubItem</span>
<h3>{{ subitem.name }}</h3>
</div>
<div class="row-meta-grid">
<span>数量:{{ subitem.quantity if subitem.quantity is not none else 1 }}</span>
<span>上级容器:{{ item.name }}</span>
</div>
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<section class="card">
<p>还没有子物品。</p>
+1 -1
View File
@@ -16,7 +16,7 @@
value="{{ query }}"
placeholder="例如:锅、电源线、冬衣、文件袋"
>
<button type="submit">搜索</button>
<button type="submit" class="button button-primary">搜索</button>
</form>
</section>
+49 -10
View File
@@ -1,28 +1,60 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/boxes">箱子</a>
<span>/</span>
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
<span>/</span>
<a href="/items/{{ item.id }}">{{ item.name }}</a>
<span>/</span>
<strong>{{ "新建 SubItem" if not subitem else "编辑 SubItem" }}</strong>
</div>
<div class="page-header">
<div>
<div class="type-tag type-subitem">SubItem</div>
<h1>{{ page_title }}</h1>
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
<p class="muted">
{% if subitem %}
你当前正在编辑一个最后一级内容。
{% else %}
你当前正在这个容器型 Item 下面添加最后一级内容。
{% endif %}
</p>
</div>
<a href="/items/{{ item.id }}">返回物品</a>
<a class="button button-secondary button-small" href="/items/{{ item.id }}">返回物品</a>
</div>
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
<section class="context-panel">
<div class="context-title">当前上下文</div>
<div class="context-body">
<span class="type-tag type-box">Box</span>
<span>{{ item.box.name }}</span>
</div>
<div class="context-body">
<span class="type-tag type-container">容器型 Item</span>
<span>{{ item.name }}</span>
</div>
<div class="context-body">
<span class="type-tag type-subitem">SubItem</span>
<span>{{ "创建最后一级内容" if not subitem else "编辑最后一级内容" }}</span>
</div>
</section>
<label class="form-field">
名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
</label>
<label>
<label class="form-field">
数量
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '' }}">
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
</label>
<label>
<label class="form-field">
备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label>
<label>
<label class="form-field">
图片
<input type="file" name="image_file" accept="image/*">
</label>
@@ -32,7 +64,7 @@
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
<button
type="submit"
class="link-button"
class="button button-danger button-small"
formaction="/subitems/{{ subitem.id }}/image/delete"
formmethod="post"
>
@@ -40,6 +72,13 @@
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
<div class="form-actions">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not subitem %}
<button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
保存并添加下一个
</button>
{% endif %}
</div>
</form>
{% endblock %}
+5 -4
View File
@@ -1,14 +1,15 @@
services:
web:
container_name: moving-helper
build:
context: .
user: "1000:1000"
user: "${UID:-1000}:${GID:-1000}"
ports:
- "${PORT:-10000}:${PORT:-10000}"
environment:
HOST: 0.0.0.0
HOST: ${HOST:-0.0.0.0}
PORT: ${PORT:-10000}
DATABASE_URL: sqlite:////app/data/app.db
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
volumes:
- ./data:/app/data
- ${DATA_DIR:-./data}:/app/data
restart: unless-stopped
+1
View File
@@ -1,3 +1,4 @@
[pytest]
pythonpath = .
filterwarnings =
ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing
+1
View File
@@ -4,5 +4,6 @@ jinja2==3.1.6
sqlalchemy==2.0.43
python-multipart==0.0.20
pillow==11.2.1
requests==2.32.3
pytest==8.4.1
httpx==0.28.1
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env sh
set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_ROOT"
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo "未找到 .env,先从 .env.example 复制一份:"
echo " cp .env.example .env"
exit 1
fi
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
DATA_DIR=${DATA_DIR_VALUE:-./data}
DB_PATH="$DATA_DIR/app.db"
if [ ! -f "$DB_PATH" ]; then
echo "未找到数据库文件:$DB_PATH"
exit 1
fi
mkdir -p backups
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
DESTINATION="backups/app-$TIMESTAMP.db"
cp "$DB_PATH" "$DESTINATION"
echo "备份已创建:$DESTINATION"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env sh
set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_ROOT"
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo "未找到 .env,先从 .env.example 复制一份:"
echo " cp .env.example .env"
exit 1
fi
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
DATA_DIR=${DATA_DIR_VALUE:-./data}
mkdir -p "$DATA_DIR"
echo "[1/4] 拉取最新代码(如果当前目录是 git 仓库)"
if [ -d ".git" ]; then
git pull --ff-only
else
echo "跳过:当前目录不是 git 仓库"
fi
echo "[2/4] 构建并更新容器"
docker compose up -d --build
echo "[3/4] 当前容器状态"
docker compose ps
echo "[4/4] 最近日志"
docker compose logs --tail=50 web
echo
echo "部署完成。应用默认地址:"
echo " http://localhost:$(grep '^PORT=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || echo 10000)"
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import getpass
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.db import SessionLocal, configure_database
from app.notion_import import (
apply_import,
extract_page_id,
fetch_page_blocks,
parse_notion_blocks,
print_summary,
)
def main() -> int:
parser = argparse.ArgumentParser(description="一次性导入 Notion 搬家记录到当前 SQLite 数据库")
parser.add_argument("--dry-run", action="store_true", help="只解析,不写数据库")
parser.add_argument("--apply", action="store_true", help="真正写入数据库")
args = parser.parse_args()
mode = _resolve_mode(args)
token = getpass.getpass("请输入 Notion API token: ").strip()
if not token:
print("未输入 token,已退出")
return 1
page_url = input("请输入 Notion 页面完整 URL: ").strip()
if not page_url:
print("未输入页面 URL,已退出")
return 1
try:
page_id = extract_page_id(page_url)
except ValueError as exc:
print(f"页面 URL 无法识别: {exc}")
return 1
print()
print(f"正在读取 Notion page: {page_id}")
try:
blocks = fetch_page_blocks(token, page_id)
except Exception as exc:
print(f"读取 Notion page 失败: {exc}")
return 1
print(f"已读取顶层及嵌套 blocks,总数约 {count_blocks(blocks)}")
print("正在解析页面结构...")
summary = parse_notion_blocks(blocks)
print_summary(summary)
if mode == "dry-run":
print()
print("dry-run 完成,未写入数据库。")
return 0
print()
print("这是一次性导入脚本,不建议在同一数据库上重复执行。")
print("建议先备份当前 SQLite 数据库,再继续。")
confirmed = input("确认执行导入?输入 yes 继续: ").strip().lower()
if confirmed != "yes":
print("已取消导入。")
return 0
configure_database()
db = SessionLocal()
try:
counts = apply_import(summary, db)
except Exception as exc:
db.rollback()
print(f"导入失败,已回滚: {exc}")
return 1
finally:
db.close()
print()
print("导入完成")
print(f"- 写入 Box: {counts['boxes']}")
print(f"- 写入 Item: {counts['items']}")
print(f"- 写入 SubItem: {counts['subitems']}")
return 0
def _resolve_mode(args: argparse.Namespace) -> str:
if args.apply and args.dry_run:
raise SystemExit("请只选择一种模式:--dry-run 或 --apply")
if args.apply:
return "apply"
return "dry-run"
def count_blocks(blocks: list[dict]) -> int:
total = 0
for block in blocks:
total += 1
total += count_blocks(block.get("_children", []))
return total
if __name__ == "__main__":
raise SystemExit(main())
+301 -6
View File
@@ -2,7 +2,9 @@ from io import BytesIO
from pathlib import Path
import app.db as db_module
import app.images as images_module
from PIL import Image
from fastapi import UploadFile
from app.models import Box, Item, SubItem
@@ -21,19 +23,28 @@ def create_item(
quantity="2",
is_container=False,
image=None,
submit_action="save",
):
data = {"name": name, "note": note, "quantity": quantity}
data = {"name": name, "note": note, "quantity": quantity, "submit_action": submit_action}
if is_container:
data["is_container"] = "on"
files = {"image_file": image} if image is not None else None
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3", image=None):
def create_subitem(
client,
item_id,
name="SubItem A",
note="Small",
quantity="3",
image=None,
submit_action="save",
):
files = {"image_file": image} if image is not None else None
return client.post(
f"/items/{item_id}/subitems",
data={"name": name, "note": note, "quantity": quantity},
data={"name": name, "note": note, "quantity": quantity, "submit_action": submit_action},
files=files,
follow_redirects=False,
)
@@ -52,6 +63,19 @@ def make_image_upload(
return (filename, output.getvalue(), "image/png")
def make_oriented_jpeg_upload(
filename="portrait.jpg",
size=(1600, 900),
orientation=6,
):
image = Image.new("RGB", size, (20, 120, 220))
exif = Image.Exif()
exif[274] = orientation
output = BytesIO()
image.save(output, format="JPEG", exif=exif)
return (filename, output.getvalue(), "image/jpeg")
def read_jpeg_size(image_bytes):
with Image.open(BytesIO(image_bytes)) as image:
return image.format, image.size
@@ -79,6 +103,40 @@ def test_boxes_page_returns_200(client):
assert "箱子" in response.text
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
box = Box(
name="Kitchen Box",
note="易碎餐具和杯子",
room="Kitchen",
status="packed",
)
box.items.append(Item(name="Plate", is_container=False))
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert "Kitchen Box" in response.text
assert "物品数:1" in response.text
assert "易碎餐具和杯子" in response.text
assert "房间:" not in response.text
assert "状态:" not in response.text
def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
box = Box(name="No Note Box", note=None, room="Office", status="open")
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert "No Note Box" in response.text
assert "房间:" not in response.text
assert "状态:" not in response.text
def test_can_create_box(client, db_session):
response = create_box(client, name="Kitchen Box")
@@ -153,6 +211,21 @@ def test_box_detail_returns_200_when_box_exists(client, db_session):
assert "Visible Box" in response.text
def test_box_detail_item_cards_show_notes_without_note_placeholder_text(client, db_session):
box = Box(name="Overview Box")
box.items.append(Item(name="Accessory Pouch", note="充电器和转换头", is_container=True, quantity=2))
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "Accessory Pouch" in response.text
assert "数量:2" in response.text
assert "充电器和转换头" in response.text
assert "有备注" not in response.text
def test_can_create_regular_item_under_box(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
@@ -320,14 +393,13 @@ def test_post_redirects_are_reasonable(client, db_session):
db_session.commit()
item_response = create_item(client, box.id, name="Lamp")
item_id = int(item_response.headers["location"].split("/")[-1])
item = db_session.get(Item, item_id)
item = db_session.query(Item).one()
item.is_container = True
db_session.commit()
subitem_response = create_subitem(client, item.id, name="Bulb")
assert item_response.headers["location"] == f"/items/{item.id}"
assert item_response.headers["location"] == f"/boxes/{box.id}"
assert subitem_response.headers["location"] == f"/items/{item.id}"
@@ -347,6 +419,21 @@ def test_can_upload_image_for_box_and_process_it(client, db_session):
assert image_size == (1600, 800)
def test_image_pipeline_applies_exif_orientation_before_saving(client, db_session):
response = create_box(client, name="Portrait Box", image=make_oriented_jpeg_upload())
assert response.status_code == 303
box = db_session.query(Box).filter_by(name="Portrait Box").one()
assert box.image_blob is not None
assert box.image_width == 900
assert box.image_height == 1600
image_format, image_size = read_jpeg_size(box.image_blob)
assert image_format == "JPEG"
assert image_size == (900, 1600)
def test_can_upload_image_for_item(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
@@ -564,6 +651,24 @@ def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client
assert updated_box.image_height is None
def test_heic_upload_returns_clear_error_if_heif_support_is_unavailable(monkeypatch):
heic_file = UploadFile(filename="sample.heic", file=BytesIO(b"not-a-real-heic"))
def fake_open(*args, **kwargs):
raise images_module.UnidentifiedImageError("cannot identify image file")
monkeypatch.setattr(images_module, "HEIF_SUPPORT_ENABLED", False)
monkeypatch.setattr(images_module.Image, "open", fake_open)
monkeypatch.setattr(images_module.shutil, "which", lambda command: None)
try:
images_module.process_upload(heic_file)
assert False, "Expected HEIC upload to raise HTTPException"
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
assert "HEIC/HEIF" in getattr(exc, "detail", "")
def test_can_search_box_by_name(client, db_session):
box = Box(name="冬季衣物箱")
db_session.add(box)
@@ -707,3 +812,193 @@ def test_search_result_without_image_does_not_break_template(client, db_session)
assert response.status_code == 200
assert "无图物品" in response.text
def test_new_box_page_shows_clear_context(client):
response = client.get("/boxes/new")
assert response.status_code == 200
assert "新建 Box" in response.text
assert "创建顶层箱子" in response.text
def test_new_item_page_shows_clear_context_and_default_quantity(client, db_session):
box = Box(name="主卧箱")
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}/items/new")
assert response.status_code == 200
assert "新建 Item" in response.text
assert "主卧箱" in response.text
assert 'name="quantity"' in response.text
assert 'value="1"' in response.text
assert "这个物品本身是一个小容器" in response.text
assert "保存并添加下一个" in response.text
def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_session):
box = Box(name="客厅箱")
item = Item(name="文件袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/items/{item.id}/subitems/new")
assert response.status_code == 200
assert "新建 SubItem" in response.text
assert "客厅箱" in response.text
assert "文件袋" in response.text
assert 'name="quantity"' in response.text
assert 'value="1"' in response.text
assert "保存并添加下一个" in response.text
def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client, db_session):
box = Box(name="厨房箱")
item = Item(name="", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "Box" in response.text
assert "厨房箱" in response.text
assert "overview-grid" in response.text
assert f'data-href="/items/{item.id}"' in response.text
assert "是否容器" not in response.text
def test_item_detail_page_renders_clear_hierarchy(client, db_session):
box = Box(name="书房箱")
item = Item(name="配件盒", box=box, is_container=True)
subitem = SubItem(name="转接头", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get(f"/items/{item.id}")
assert response.status_code == 200
assert "容器型 Item" in response.text
assert "书房箱" in response.text
assert "SubItem" in response.text
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
assert "overview-grid" in response.text
def test_box_detail_page_shows_primary_and_secondary_cta_buttons(client, db_session):
box = Box(name="操作箱")
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "button button-primary" in response.text
assert "button button-secondary" in response.text
assert "删除箱子" in response.text
def test_boxes_overview_uses_clickable_cards_without_detail_edit_buttons(client, db_session):
box = Box(name="可点击箱子")
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert f'data-href="/boxes/{box.id}"' in response.text
assert "查看详情" not in response.text
assert "编辑</a>" not in response.text
def test_item_detail_page_shows_primary_action_for_adding_subitems(client, db_session):
box = Box(name="容器箱")
item = Item(name="收纳盒", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/items/{item.id}")
assert response.status_code == 200
assert "添加子物品" in response.text
assert "button button-primary" in response.text
def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
box = Box(name="连续录入箱")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="剪刀", is_container=False)
assert response.status_code == 303
assert response.headers["location"] == f"/boxes/{box.id}"
def test_creating_regular_subitem_redirects_back_to_parent_item(client, db_session):
box = Box(name="配件箱")
item = Item(name="线材袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(client, item.id, name="USB头")
assert response.status_code == 303
assert response.headers["location"] == f"/items/{item.id}"
def test_creating_box_redirects_to_new_box_detail(client):
response = create_box(client, name="新箱子")
assert response.status_code == 303
assert response.headers["location"].startswith("/boxes/")
assert not response.headers["location"].endswith("/items/new")
def test_creating_container_item_redirects_to_item_detail(client, db_session):
box = Box(name="子容器箱")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="小收纳盒", is_container=True)
created_item = db_session.query(Item).one()
assert response.status_code == 303
assert response.headers["location"] == f"/items/{created_item.id}"
def test_creating_item_with_save_and_add_next_returns_to_same_new_item_context(client, db_session):
box = Box(name="快速录入箱")
db_session.add(box)
db_session.commit()
response = create_item(
client,
box.id,
name="袜子",
is_container=False,
submit_action="save_and_add_next",
)
assert response.status_code == 303
assert response.headers["location"] == f"/boxes/{box.id}/items/new"
def test_creating_subitem_with_save_and_add_next_returns_to_same_new_subitem_context(client, db_session):
box = Box(name="电子箱")
item = Item(name="配件袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(
client,
item.id,
name="转接头",
submit_action="save_and_add_next",
)
assert response.status_code == 303
assert response.headers["location"] == f"/items/{item.id}/subitems/new"
+143
View File
@@ -0,0 +1,143 @@
from app.models import Box, Item, SubItem
from app.notion_import import (
ImportSummary,
ParsedBox,
ParsedItem,
ParsedSubItem,
apply_import,
extract_page_id,
parse_notion_blocks,
)
def make_heading_2(text: str) -> dict:
return {
"type": "heading_2",
"heading_2": {"rich_text": [{"plain_text": text}]},
"_children": [],
}
def make_bullet(text: str, children: list[dict] | None = None) -> dict:
return {
"type": "bulleted_list_item",
"bulleted_list_item": {"rich_text": [{"plain_text": text}]},
"_children": children or [],
}
def make_image_block() -> dict:
return {"type": "image", "image": {}, "_children": []}
def test_extract_page_id_from_notion_url():
url = "https://www.notion.so/workspace/My-Page-1234567890abcdef1234567890abcdef?pvs=4"
page_id = extract_page_id(url)
assert page_id == "12345678-90ab-cdef-1234-567890abcdef"
def test_parse_heading_2_as_box():
summary = parse_notion_blocks([make_heading_2("厨房箱")])
assert summary.box_count == 1
assert summary.boxes[0].name == "厨房箱"
def test_parse_first_level_bullet_as_item():
blocks = [make_heading_2("客厅箱"), make_bullet("锅具")]
summary = parse_notion_blocks(blocks)
assert summary.item_count == 1
assert summary.boxes[0].items[0].name == "锅具"
assert summary.boxes[0].items[0].is_container is False
def test_parse_bullet_with_children_as_container_item_and_subitems():
blocks = [
make_heading_2("电子箱"),
make_bullet("配件盒", children=[make_bullet("USB 线"), make_bullet("转接头")]),
]
summary = parse_notion_blocks(blocks)
item = summary.boxes[0].items[0]
assert item.name == "配件盒"
assert item.is_container is True
assert [subitem.name for subitem in item.subitems] == ["USB 线", "转接头"]
def test_parse_second_level_bullets_as_subitems():
blocks = [
make_heading_2("文件箱"),
make_bullet("文件袋", children=[make_bullet("合同"), make_bullet("护照复印件")]),
]
summary = parse_notion_blocks(blocks)
assert summary.subitem_count == 2
assert summary.boxes[0].items[0].subitems[1].name == "护照复印件"
def test_parse_deeper_than_supported_levels_adds_warning():
blocks = [
make_heading_2("测试箱"),
make_bullet(
"外层袋",
children=[make_bullet("内层物品", children=[make_bullet("更深一层")])],
),
]
summary = parse_notion_blocks(blocks)
assert summary.container_item_count == 1
assert any("超出支持层级" in warning for warning in summary.warnings)
def test_parse_non_text_media_block_adds_skip_warning():
blocks = [make_heading_2("照片箱"), make_image_block()]
summary = parse_notion_blocks(blocks)
assert any("这版不导入图片或媒体" in warning for warning in summary.warnings)
def test_dry_run_parse_does_not_write_database(db_session):
blocks = [make_heading_2("厨房箱"), make_bullet("")]
summary = parse_notion_blocks(blocks)
assert summary.box_count == 1
assert db_session.query(Box).count() == 0
assert db_session.query(Item).count() == 0
assert db_session.query(SubItem).count() == 0
def test_apply_import_writes_expected_structure(db_session):
summary = ImportSummary(
boxes=[
ParsedBox(
name="主卧箱",
items=[
ParsedItem(name="衣服", is_container=False),
ParsedItem(
name="收纳袋",
is_container=True,
subitems=[ParsedSubItem(name="袜子"), ParsedSubItem(name="围巾")],
),
],
)
]
)
counts = apply_import(summary, db_session)
assert counts == {"boxes": 1, "items": 2, "subitems": 2}
assert db_session.query(Box).count() == 1
assert db_session.query(Item).count() == 2
assert db_session.query(SubItem).count() == 2
container_item = db_session.query(Item).filter_by(name="收纳袋").one()
assert container_item.is_container is True