11 Commits

Author SHA1 Message Date
tliu93 49a5452141 Add local-network deployment automation and tighten runtime defaults
test / pytest (push) Successful in 35s
docker-image / build-and-push (push) Successful in 4m8s
This commit adds the first complete local-network deployment path for the project. It normalizes the runtime contract around a fixed container listener on 0.0.0.0:10000, binds the published compose port to 127.0.0.1, and keeps the image/build workflow aligned with the released container image.

It also introduces an installation script, an nginx reverse-proxy template, and a safer SQLite backup flow based on sqlite3 .backup with retention and optional rclone upload support. Deployment-oriented configuration has been consolidated into .env.example, repository-local .env files are now ignored, and the deployment scripts are executable.

In addition, the frontend mixed-content issue is fixed by switching the stylesheet reference to a root-relative static path, with tests updated to cover the regression. README guidance has been expanded to document the new install, nginx, backup, and restore conventions.
2026-04-21 22:39:47 +02:00
tliu93 eb29f03b74 add compose file for pulling
test / pytest (push) Successful in 38s
2026-04-21 22:01:23 +02:00
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
29 changed files with 1885 additions and 175 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
+32
View File
@@ -0,0 +1,32 @@
# This file is sourced by shell scripts. Keep values shell-compatible.
# Local TLS domain used by nginx and your own certificate files.
HOST_DOMAIN=moving-helper.lan
# Certificate directory prepared by the user.
# Place fullchain.pem and privkey.key in this directory.
# If you use acme.sh, this is typically /etc/acme.sh/$HOST_DOMAIN
SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
# Deployment target directory used by the install script.
APP_DIR=$HOME/.local/share/moving-helper
# Backup destination directory used by the deployed backup script.
BACKUP_DIR=$HOME/.local/backup/moving-helper
# Optional rclone remote target, for example: remote:folder/moving-helper
BACKUP_REMOTE=
# Host port published by docker compose. The container always listens on 10000.
APP_PORT=10000
# Database location inside the container.
DATABASE_URL=sqlite:////app/data/app.db
# Host-side persistent data directory.
# Relative paths are resolved from APP_DIR after installation.
DATA_DIR=./data
# Optional compose project name.
COMPOSE_PROJECT_NAME=moving-helper
+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
+11
View File
@@ -2,4 +2,15 @@
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.pyc *.pyc
.env
data/*.db data/*.db
# macOS generated files
.DS_Store
**/.DS_Store
._*
**/._*
.Spotlight-V100
**/.Spotlight-V100
.Trashes
**/.Trashes
+1 -4
View File
@@ -3,8 +3,6 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
HOST=0.0.0.0 \
PORT=10000 \
DATABASE_URL=sqlite:////app/data/app.db DATABASE_URL=sqlite:////app/data/app.db
WORKDIR /app WORKDIR /app
@@ -13,10 +11,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY tests ./tests
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 10000 EXPOSE 10000
CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-10000}"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10000"]
+470 -66
View File
@@ -10,7 +10,17 @@
- pytest / FastAPI TestClient - pytest / FastAPI TestClient
- Docker / Docker Compose - 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 └── SubItem
``` ```
## 当前已支持
目前已支持的基础能力:
- Box 列表、详情、新建、编辑、删除
- Item 新建、详情、编辑、删除
- SubItem 新建、编辑、删除
- Box / Item / SubItem 单张图片上传、替换、删除、展示
- Box / Item / SubItem 全局搜索
- `/` 重定向到 `/boxes`
- Jinja2 模板渲染
- 静态文件挂载
- SQLite 持久化
- Docker 长期运行
- 基础自动化测试
删除规则:
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
- 删除容器型 `Item` 时,会级联删除其下 `SubItem`
## 图片能力说明 ## 图片能力说明
这一阶段的图片系统保持简单直接: 图片系统保持简单直接:
- `Box` 最多支持 1 张图片 - `Box` 最多支持 1 张图片
- `Item` 最多支持 1 张图片 - `Item` 最多支持 1 张图片
@@ -67,10 +56,7 @@ Box
- 支持上传、替换、删除 - 支持上传、替换、删除
- 不支持多图 - 不支持多图
图片主要用途是帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。 图片主要用帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。它不是原图归档系统。
它不是一个原图归档系统。
### 图片处理方式
上传图片后,系统会使用 Pillow 做统一处理: 上传图片后,系统会使用 Pillow 做统一处理:
@@ -87,7 +73,7 @@ Box
- `image_width` - `image_width`
- `image_height` - `image_height`
图片访问通过普通 HTTP 路由返回 JPEG 数据,例如: 图片访问路由例如:
- `/boxes/{id}/image` - `/boxes/{id}/image`
- `/items/{id}/image` - `/items/{id}/image`
@@ -95,10 +81,10 @@ Box
## 全局搜索 ## 全局搜索
当前已经支持一个轻量的全局搜索页: 当前支持一个轻量的全局搜索页:
- 路由:`/search` - 路由:`/search`
- 使用 query parameter,例如:`/search?q=电源线` - 方式:`GET /search?q=关键词`
搜索范围包括: 搜索范围包括:
@@ -109,13 +95,13 @@ Box
- `SubItem.name` - `SubItem.name`
- `SubItem.note` - `SubItem.note`
当前使用 SQLite 上的简单模糊匹配完成搜索,不引入外部搜索引擎或复杂全文系统。 当前使用 SQLite 上的简单模糊匹配,不引入外部搜索引擎或复杂全文系统。
搜索结果会尽量帮助你快速定位 搜索结果会显示
- 显示对象类型:`Box / Item / SubItem` - 对象类型:`Box / Item / SubItem`
- 显示名称和备注 - 名称和备注
- 显示归属路径 - 归属路径
-`Item` 展示所属 `Box` -`Item` 展示所属 `Box`
-`SubItem` 展示所属 `Item``Box` -`SubItem` 展示所属 `Item``Box`
- 如果对象已有图片,会显示一个小缩略图 - 如果对象已有图片,会显示一个小缩略图
@@ -124,7 +110,6 @@ Box
这一阶段仍然没有实现以下内容: 这一阶段仍然没有实现以下内容:
- 搜索
- 多图上传 - 多图上传
- OCR - OCR
- AI 识别物品 - AI 识别物品
@@ -149,14 +134,16 @@ Box
│ ├── static │ ├── static
│ │ └── style.css │ │ └── style.css
│ └── templates │ └── templates
│ ├── base.html
│ ├── boxes
│ ├── items
│ └── subitems
├── data ├── data
├── scripts
│ ├── backup_db.sh
│ └── deploy.sh
│ ├── install.sh
│ └── nginx
│ └── moving-helper.nginx.template
├── tests ├── tests
│ ├── conftest.py ├── .dockerignore
│ └── test_app.py ├── .env.example
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
├── pytest.ini ├── pytest.ini
@@ -166,17 +153,46 @@ Box
## 轻量配置 ## 轻量配置
项目通过环境变量支持以下配置 项目通过环境变量支持以下部署时真正需要关心的配置:
- `HOST_DOMAIN`
- `SSL_PATH`
- `APP_DIR`
- `BACKUP_DIR`
- `BACKUP_REMOTE`
- `APP_PORT`
- `DATA_DIR`
- `DATABASE_URL` - `DATABASE_URL`
- `HOST` - `COMPOSE_PROJECT_NAME`
- `PORT`
默认值 推荐从示例文件开始
- `DATABASE_URL=sqlite:///./data/app.db` ```bash
- `HOST=0.0.0.0` cp .env.example .env
- `PORT=10000` ```
默认值如下:
```env
HOST_DOMAIN=moving-helper.lan
SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
APP_DIR=$HOME/.local/share/moving-helper
BACKUP_DIR=$HOME/.local/backup/moving-helper
BACKUP_REMOTE=
APP_PORT=10000
DATABASE_URL=sqlite:////app/data/app.db
DATA_DIR=./data
COMPOSE_PROJECT_NAME=moving-helper
```
说明:
- 容器内应用固定监听 `0.0.0.0:10000`
- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口
- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录
- `DATA_DIR` 默认为相对路径 `./data`,安装后会相对于 `APP_DIR` 解析
- `SSL_PATH` 由用户自行准备证书目录,安装脚本不会签发证书
- `.env` 会被 shell 脚本直接 `source`,请保持 shell 兼容写法
## 本地开发模式 ## 本地开发模式
@@ -213,14 +229,54 @@ http://localhost:10000
./data/app.db ./data/app.db
``` ```
## Docker 部署模 ## Docker 运行方
Docker / Compose 是这个项目面向长期运行环境的方式。 Docker / Compose 是这个项目面向长期运行环境的方式。
启动 当前 compose 同时保留了
- `image`:固定指向 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
- `build`:用于本地开发时从当前代码构建镜像
当前部署约定已经收敛为:
- 容器内应用固定监听 `0.0.0.0:10000`
- compose 固定使用 `user: 1000:1000`
- 宿主机仅在 `127.0.0.1:${APP_PORT}` 暴露后端端口
- SQLite 固定写入容器内 `/app/data/app.db`
### 首次准备
```bash ```bash
docker compose up --build cp .env.example .env
mkdir -p data
```
### 启动 / 更新:本地代码构建
```bash
docker compose up -d --build
```
这个模式会使用当前仓库代码重新构建镜像,适合本地开发、调试或尚未发布 tag 的阶段。
### 启动 / 更新:直接拉取已发布镜像
```bash
docker compose pull
docker compose up -d
```
### 查看状态
```bash
docker compose ps
```
### 查看日志
```bash
docker compose logs -f web
``` ```
访问: 访问:
@@ -229,20 +285,214 @@ docker compose up --build
http://localhost:10000 http://localhost:10000
``` ```
说明: ### Compose 配置说明
- 默认暴露 `10000` 端口 当前 `docker-compose.yml` 保持尽量简单:
- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000`
- `restart: unless-stopped` - `restart: unless-stopped`
- 容器使用 `1000:1000` 运行 - 容器固定使用 `1000:1000`
- SQLite 文件持久化到宿主机 `./data/app.db` - 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
- 容器重建不会丢失数据 - SQLite 默认写入 `/app/data/app.db`
备份时直接复制 SQLite 文件即可 因此同一个 compose 文件可以覆盖两种使用方式
- 本地开发容器:`docker compose up -d --build`
- 远端部署发布镜像:`docker compose pull && docker compose up -d`
## 自动化部署
这个项目现在额外提供一个面向本地网络环境的最小安装脚本:
```bash
sh scripts/install.sh
```
安装脚本会执行:
1. 检查项目根目录下是否存在 `.env`
2. 读取 `.env`
3.`docker-compose.yml``.env` 和渲染后的 `backup_db.sh` 复制到 `APP_DIR`
4.`HOST_DOMAIN``SSL_PATH``APP_PORT` 渲染 nginx 配置
5. 写入 `/etc/nginx/sites-available/moving-helper-nginx`
6. 创建到 `/etc/nginx/sites-enabled/` 的符号链接
7. 执行 `nginx -t` 并 reload nginx
8.`APP_DIR` 下执行 `docker compose pull``docker compose up -d`
9. 为当前用户写入每日 `02:10` 的 backup cron
其中以下步骤需要 root 或 sudo:
- 写入 nginx 配置
- 执行 `nginx -t`
- reload nginx
如果 `.env` 不存在,脚本会直接退出,不会继续做任何安装动作。
如果你只想在仓库目录里做一次手动更新,也保留了一个轻量部署脚本:
```bash
./scripts/deploy.sh
```
它会按顺序执行:
1. 检查 `.env`
2. 准备数据目录
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
4. 执行 `docker compose pull web`
5. 执行 `docker compose up -d`
5. 输出容器状态
6. 输出最近日志
这个脚本的目标不是做平台化发布,而是让“更新代码并刷新容器”变成一个稳定、可重复执行的动作。
如果你不想自动拉代码,也可以直接手动运行:
```bash
docker compose pull
docker compose up -d
```
## 数据持久化
当前 SQLite 文件默认会保存在:
- 宿主机:`./data/app.db`
- 容器内:`/app/data/app.db`
这是因为 `docker-compose.yml` 把:
```text ```text
./data/app.db ${DATA_DIR:-./data}
``` ```
挂载到了容器内:
```text
/app/data
```
因此:
- 容器重建不会删除宿主机上的数据库文件
- 更新镜像不会导致 SQLite 数据丢失
- 只要保留 `DATA_DIR` 目录,数据就还在
## 备份与恢复
### 备份机制
安装脚本会把渲染后的备份脚本安装到:
- `APP_DIR/backup_db.sh`
并为当前用户创建一条 cron
- `10 2 * * *`
备份行为如下:
- 目标目录是 `BACKUP_DIR`
- 备份文件名带时间戳,例如 `app-20260421-021000.db`
- 最多保留 5 个本地备份
- 如果 `BACKUP_REMOTE` 非空,会在本地备份完成后调用 `rclone copyto`
SQLite 一致性策略:
- 备份脚本优先使用 `sqlite3``.backup`
- 不停容器
- 不直接 `cp` 正在写入的数据库文件
这样可以在应用仍然运行时生成事务一致的快照,避免简单文件复制带来的损坏风险。
### 手动执行备份
如果你想手动触发一次备份:
```bash
sh "$APP_DIR/backup_db.sh"
```
如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行:
```bash
./scripts/backup_db.sh
```
前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板。
### 恢复大致步骤
停止容器后,把备份文件覆盖回去:
```bash
cd "$APP_DIR"
docker compose stop
cp "$BACKUP_DIR/app-YYYYMMDD-HHMMSS.db" "${DATA_DIR:-./data}/app.db"
docker compose up -d
```
如果 `DATA_DIR` 是相对路径,记得在 `APP_DIR` 下执行这些命令。
### nginx 与证书约定
仓库提供的 nginx 模板位于:
- `scripts/nginx/moving-helper.nginx.template`
安装脚本会把它渲染成 Debian / Ubuntu 风格的站点配置:
- `/etc/nginx/sites-available/moving-helper-nginx`
- `/etc/nginx/sites-enabled/moving-helper-nginx`
模板约定:
- 80 端口强制跳转到 443
- 443 默认启用 SSL
- 反代到仅绑定在本机回环地址上的 `127.0.0.1:${APP_PORT}`
- `client_max_body_size 0`
证书文件需要由用户自己准备在 `SSL_PATH` 下,当前模板默认引用:
- `fullchain.pem`
- `privkey.key`
## 常见排查
### 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 +506,164 @@ python -m pytest
当前测试覆盖包括: 当前测试覆盖包括:
- Box / Item / SubItem 基础 CRUD - Box / Item / SubItem 基础 CRUD
- 404 返回 - 图片上传、替换、删除与错误路径
- 非容器 Item 不能创建 SubItem - 全局搜索 name / note
- Box / Item 删除后的级联删除 - 创建后的重定向行为
- 图片上传、转换为 JPEG、缩放、读取、替换、删除 - 关键页面结构和 UX 文案
- 全局搜索 name / note,并展示对象类型与归属路径
- 无图片访问和非法图片上传等错误路径 ## CI / CD
- 关键 POST 请求后的重定向行为
仓库现在包含两条基础自动化流程,文件位于:
- `.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 dataclasses import dataclass
from io import BytesIO from io import BytesIO
from pathlib import Path
import shutil
import subprocess
from tempfile import TemporaryDirectory
from fastapi import HTTPException, UploadFile 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 MAX_IMAGE_SIDE = 1600
@@ -22,6 +34,8 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
if file is None or not file.filename: if file is None or not file.filename:
return None return None
suffix = Path(file.filename).suffix.lower()
try: try:
raw_bytes = file.file.read() raw_bytes = file.file.read()
if not raw_bytes: 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: with Image.open(BytesIO(raw_bytes)) as source_image:
processed_image = _prepare_image(source_image) processed_image = _prepare_image(source_image)
except UnidentifiedImageError as exc: 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 raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
except HTTPException: except HTTPException:
raise raise
@@ -42,7 +59,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
def _prepare_image(source_image: Image.Image) -> ProcessedImage: 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)) prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
output = BytesIO() 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.convert("RGB")
return source_image.copy() 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
+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,
}
+121 -29
View File
@@ -61,6 +61,13 @@ button,
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
padding: 10px 14px; padding: 10px 14px;
text-decoration: none;
line-height: 1.2;
}
.button-primary {
background: #0b57d0;
color: #fff;
} }
.button-secondary { .button-secondary {
@@ -69,9 +76,21 @@ button,
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
} }
.button-danger {
background: #b42318;
color: #fff;
}
.button-small {
padding: 8px 12px;
font-size: 0.92rem;
}
.button:hover, .button:hover,
button:hover { button:hover,
opacity: 0.92; .button:focus-visible,
button:focus-visible {
opacity: 0.94;
text-decoration: none; text-decoration: none;
} }
@@ -92,6 +111,10 @@ button:hover {
flex-wrap: wrap; flex-wrap: wrap;
} }
.page-header {
margin-bottom: 10px;
}
.stack { .stack {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -178,9 +201,14 @@ button:hover {
margin-bottom: 16px; margin-bottom: 16px;
} }
.detail-image-compact {
max-width: 180px;
margin-bottom: 8px;
}
.thumb-image { .thumb-image {
display: block; display: block;
width: 88px; width: 64px;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border-radius: 8px; border-radius: 8px;
@@ -189,7 +217,7 @@ button:hover {
} }
.compact-thumb { .compact-thumb {
flex: 0 0 88px; flex: 0 0 64px;
} }
.dense-list { .dense-list {
@@ -197,21 +225,39 @@ button:hover {
gap: 8px; gap: 8px;
} }
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.compact-row { .compact-row {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
gap: 12px; gap: 10px;
align-items: start; align-items: start;
padding: 10px 12px; padding: 8px 10px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 10px; border-radius: 10px;
background: #fafafa; background: #fafafa;
position: relative;
} }
.compact-row-box { .compact-row-box {
grid-template-columns: 1fr auto; 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 { .compact-row-container {
border-left: 4px solid #d98700; border-left: 4px solid #d98700;
} }
@@ -227,6 +273,12 @@ button:hover {
.compact-main h2, .compact-main h2,
.compact-main h3 { .compact-main h3 {
margin-bottom: 4px; margin-bottom: 4px;
font-size: 1rem;
}
.overview-card .compact-main h2,
.overview-card .compact-main h3 {
font-size: 1.02rem;
} }
.row-title-line { .row-title-line {
@@ -240,24 +292,54 @@ button:hover {
.row-meta-grid { .row-meta-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 4px 12px; gap: 2px 10px;
color: #555; color: #555;
font-size: 0.95rem; font-size: 0.9rem;
}
.overview-card .row-meta-grid {
grid-template-columns: 1fr;
gap: 2px;
} }
.row-note { .row-note {
margin-top: 6px; margin-top: 4px;
margin-bottom: 0; margin-bottom: 0;
color: #333; color: #333;
font-size: 0.95rem; font-size: 0.9rem;
} }
.row-actions { .clickable-card {
display: flex; cursor: pointer;
flex-direction: column; transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
gap: 6px; }
align-items: flex-end;
white-space: nowrap; .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, .meta,
@@ -324,18 +406,6 @@ button:hover {
margin: 0; margin: 0;
} }
.link-button {
background: none;
border: none;
color: #b42318;
padding: 0;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.container { .container {
margin: 0; margin: 0;
@@ -356,6 +426,28 @@ button:hover {
.thumb-image, .thumb-image,
.compact-thumb { .compact-thumb {
width: 100px; width: 72px;
}
.detail-card-compact {
grid-template-columns: 1fr;
}
}
@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));
} }
} }
+19 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title or "搬家助手" }}</title> <title>{{ page_title or "搬家助手" }}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<main class="container"> <main class="container">
@@ -15,6 +15,24 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script> <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) { document.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.target.tagName === "TEXTAREA") return; if (event.target.tagName === "TEXTAREA") return;
+5 -3
View File
@@ -18,7 +18,7 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
<a href="/boxes">返回箱子列表</a> <a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data"> <form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
@@ -55,7 +55,7 @@
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image"> <img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
<button <button
type="submit" type="submit"
class="link-button" class="button button-danger button-small"
formaction="/boxes/{{ box.id }}/image/delete" formaction="/boxes/{{ box.id }}/image/delete"
formmethod="post" formmethod="post"
> >
@@ -63,6 +63,8 @@
</button> </button>
</section> </section>
{% endif %} {% endif %}
<button type="submit">{{ submit_label }}</button> <div class="form-actions">
<button type="submit" class="button button-primary">{{ submit_label }}</button>
</div>
</form> </form>
{% endblock %} {% endblock %}
+11 -11
View File
@@ -12,36 +12,36 @@
<h1>箱子总览</h1> <h1>箱子总览</h1>
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p> <p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
</div> </div>
<a class="button" href="/boxes/new">新建箱子</a> <a class="button button-primary" href="/boxes/new">新建箱子</a>
</div> </div>
{% if boxes %} {% if boxes %}
<div class="dense-list"> <div class="overview-grid">
{% for box in boxes %} {% for box in boxes %}
<section class="compact-row compact-row-box"> <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="compact-main">
<div class="row-title-line"> <div class="row-title-line">
<span class="type-tag type-box">Box</span> <span class="type-tag type-box">Box</span>
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2> <h2>{{ box.name }}</h2>
</div> </div>
<div class="row-meta-grid"> <div class="row-meta-grid">
<span>物品数:{{ box.items|length }}</span> <span>物品数:{{ box.items|length }}</span>
<span>房间:{{ box.room or '-' }}</span>
<span>状态:{{ box.status or '-' }}</span>
</div> </div>
{% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %} {% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %}
</div> </div>
<div class="row-actions">
<a href="/boxes/{{ box.id }}">查看详情</a>
<a href="/boxes/{{ box.id }}/edit">编辑</a>
</div>
</section> </section>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<section class="card"> <section class="card">
<p>还没有箱子。</p> <p>还没有箱子。</p>
<a href="/boxes/new">创建第一个箱子</a> <a class="button button-primary" href="/boxes/new">创建第一个箱子</a>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+25 -25
View File
@@ -13,33 +13,44 @@
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p> <p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
</div> </div>
<div class="actions"> <div class="actions">
<a href="/boxes">返回箱子列表</a> <a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
<a href="/search">去搜索</a> <a class="button button-secondary button-small" href="/search">去搜索</a>
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a> <a class="button button-primary" href="/boxes/{{ box.id }}/items/new">添加物品</a>
</div> </div>
</div> </div>
<section class="card"> <section class="card detail-card detail-card-compact">
{% if box.image_blob %} {% 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 %} {% endif %}
<p><strong>房间:</strong> {{ box.room or '-' }}</p> <div class="detail-meta-grid">
<p><strong>状态</strong> {{ box.status or '-' }}</p> <p><strong>房间</strong> {{ box.room or '-' }}</p>
<p><strong>备注</strong> {{ box.note or '-' }}</p> <p><strong>状态</strong> {{ box.status or '-' }}</p>
<p class="detail-note"><strong>备注:</strong> {{ box.note or '-' }}</p>
</div>
<div class="actions"> <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"> <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> </form>
</div> </div>
</section> </section>
<section class="stack"> <section class="stack">
<h2>物品</h2> <div class="section-heading">
<h2>内部物品</h2>
<p class="muted">重点浏览区域,点击任意一行可进入物品详情。</p>
</div>
{% if box.items %} {% if box.items %}
<div class="dense-list"> <div class="overview-grid">
{% for item in box.items %} {% for item in box.items %}
<article class="compact-row {{ 'compact-row-container' if item.is_container else 'compact-row-item' }}"> <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 %} {% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb"> <img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb">
{% endif %} {% endif %}
@@ -48,24 +59,13 @@
<span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}"> <span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
{{ "容器型 Item" if item.is_container else "Item" }} {{ "容器型 Item" if item.is_container else "Item" }}
</span> </span>
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3> <h3>{{ item.name }}</h3>
</div> </div>
<div class="row-meta-grid"> <div class="row-meta-grid">
<span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span> <span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span>
<span>是否容器:{{ "是" if item.is_container else "否" }}</span>
</div> </div>
{% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %} {% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %}
</div> </div>
<div class="row-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>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+4 -4
View File
@@ -23,7 +23,7 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
<a href="/boxes/{{ box.id }}">返回箱子</a> <a class="button button-secondary button-small" href="/boxes/{{ box.id }}">返回箱子</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data"> <form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
@@ -69,7 +69,7 @@
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image"> <img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
<button <button
type="submit" type="submit"
class="link-button" class="button button-danger button-small"
formaction="/items/{{ item.id }}/image/delete" formaction="/items/{{ item.id }}/image/delete"
formmethod="post" formmethod="post"
> >
@@ -78,9 +78,9 @@
</section> </section>
{% endif %} {% endif %}
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="submit_action" value="save">{{ submit_label }}</button> <button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not item %} {% if not item %}
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary"> <button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
保存并添加下一个 保存并添加下一个
</button> </button>
{% endif %} {% endif %}
+23 -19
View File
@@ -17,22 +17,23 @@
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a></p> <p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a></p>
</div> </div>
<div class="actions"> <div class="actions">
<a href="/boxes/{{ item.box.id }}">返回箱子</a> <a class="button button-secondary button-small" href="/boxes/{{ item.box.id }}">返回箱子</a>
<a href="/search">去搜索</a> <a class="button button-secondary button-small" href="/search">去搜索</a>
<a href="/items/{{ item.id }}/edit">编辑物品</a> <a class="button button-secondary button-small" href="/items/{{ item.id }}/edit">编辑物品</a>
</div> </div>
</div> </div>
<section class="card"> <section class="card detail-card detail-card-compact">
{% if item.image_blob %} {% 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 %} {% endif %}
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p> <div class="detail-meta-grid">
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p> <p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
<p><strong>备注:</strong> {{ item.note or '-' }}</p> <p class="detail-note"><strong>备注:</strong> {{ item.note or '-' }}</p>
</div>
<div class="actions"> <div class="actions">
<form method="post" action="/items/{{ item.id }}/delete"> <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> </form>
</div> </div>
</section> </section>
@@ -40,13 +41,22 @@
{% if item.is_container %} {% if item.is_container %}
<section class="stack"> <section class="stack">
<div class="page-header"> <div class="page-header">
<h2>子物品</h2> <div class="section-heading">
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a> <h2>内部子物品</h2>
<p class="muted">当前容器里装的内容,点击任意一行可进入对应编辑上下文。</p>
</div>
<a class="button button-primary" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
</div> </div>
{% if item.subitems %} {% if item.subitems %}
<div class="dense-list"> <div class="overview-grid">
{% for subitem in item.subitems %} {% for subitem in item.subitems %}
<article class="compact-row compact-row-subitem"> <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 %} {% if subitem.image_blob %}
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb"> <img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb">
{% endif %} {% endif %}
@@ -61,12 +71,6 @@
</div> </div>
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %} {% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
</div> </div>
<div class="row-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>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+1 -1
View File
@@ -16,7 +16,7 @@
value="{{ query }}" value="{{ query }}"
placeholder="例如:锅、电源线、冬衣、文件袋" placeholder="例如:锅、电源线、冬衣、文件袋"
> >
<button type="submit">搜索</button> <button type="submit" class="button button-primary">搜索</button>
</form> </form>
</section> </section>
+4 -4
View File
@@ -23,7 +23,7 @@
{% endif %} {% endif %}
</p> </p>
</div> </div>
<a href="/items/{{ item.id }}">返回物品</a> <a class="button button-secondary button-small" href="/items/{{ item.id }}">返回物品</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data"> <form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
@@ -64,7 +64,7 @@
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image"> <img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
<button <button
type="submit" type="submit"
class="link-button" class="button button-danger button-small"
formaction="/subitems/{{ subitem.id }}/image/delete" formaction="/subitems/{{ subitem.id }}/image/delete"
formmethod="post" formmethod="post"
> >
@@ -73,9 +73,9 @@
</section> </section>
{% endif %} {% endif %}
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="submit_action" value="save">{{ submit_label }}</button> <button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not subitem %} {% if not subitem %}
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary"> <button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
保存并添加下一个 保存并添加下一个
</button> </button>
{% endif %} {% endif %}
+5 -5
View File
@@ -1,14 +1,14 @@
services: services:
web: web:
container_name: moving-helper
image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest"
build: build:
context: . context: .
user: "1000:1000" user: "1000:1000"
ports: ports:
- "${PORT:-10000}:${PORT:-10000}" - "127.0.0.1:${APP_PORT:-10000}:10000"
environment: environment:
HOST: 0.0.0.0 DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
PORT: ${PORT:-10000}
DATABASE_URL: sqlite:////app/data/app.db
volumes: volumes:
- ./data:/app/data - ${DATA_DIR:-./data}:/app/data
restart: unless-stopped restart: unless-stopped
+1
View File
@@ -1,3 +1,4 @@
[pytest] [pytest]
pythonpath = .
filterwarnings = filterwarnings =
ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing 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 sqlalchemy==2.0.43
python-multipart==0.0.20 python-multipart==0.0.20
pillow==11.2.1 pillow==11.2.1
requests==2.32.3
pytest==8.4.1 pytest==8.4.1
httpx==0.28.1 httpx==0.28.1
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env sh
set -eu
APP_DIR="__APP_DIR__"
DEFAULT_BACKUP_DIR="__BACKUP_DIR__"
ENV_FILE="$APP_DIR/.env"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
resolve_path() {
case "$1" in
/*) printf '%s\n' "$1" ;;
*) printf '%s/%s\n' "$APP_DIR" "$1" ;;
esac
}
if [ ! -f "$ENV_FILE" ]; then
echo "Deployed .env file not found: $ENV_FILE" >&2
exit 1
fi
set -a
. "$ENV_FILE"
set +a
require_command sqlite3
if [ -n "${BACKUP_REMOTE:-}" ]; then
require_command rclone
fi
BACKUP_DIR=${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}
DATA_DIR=${DATA_DIR:-./data}
DB_PATH="$(resolve_path "$DATA_DIR")/app.db"
if [ ! -f "$DB_PATH" ]; then
echo "Database file not found: $DB_PATH" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
TMP_BACKUP="$BACKUP_DIR/.app-$TIMESTAMP.db.tmp"
FINAL_BACKUP="$BACKUP_DIR/app-$TIMESTAMP.db"
cleanup() {
rm -f "$TMP_BACKUP"
}
trap cleanup EXIT INT TERM
# Prefer sqlite3 .backup so the snapshot stays transactionally consistent without
# stopping the running container or racing with SQLite writes.
sqlite3 "$DB_PATH" <<EOF
.timeout 5000
.backup $TMP_BACKUP
EOF
mv "$TMP_BACKUP" "$FINAL_BACKUP"
trap - EXIT INT TERM
count=0
for backup_file in $(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'app-*.db' | sort -r); do
count=$((count + 1))
if [ "$count" -gt 5 ]; then
rm -f "$backup_file"
fi
done
if [ -n "${BACKUP_REMOTE:-}" ]; then
remote_target=${BACKUP_REMOTE%/}/$(basename "$FINAL_BACKUP")
rclone copyto "$FINAL_BACKUP" "$remote_target"
echo "Backup uploaded to remote: $remote_target"
else
echo "BACKUP_REMOTE is empty; skipping remote upload"
fi
echo "Backup created: $FINAL_BACKUP"
+43
View File
@@ -0,0 +1,43 @@
#!/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 not found. Create it first from .env.example:"
echo " cp .env.example .env"
exit 1
fi
set -a
. ./.env
set +a
DATA_DIR=${DATA_DIR:-./data}
APP_PORT=${APP_PORT:-10000}
mkdir -p "$DATA_DIR"
echo "[1/4] Pull latest code if this directory is a git repository"
if [ -d ".git" ]; then
git pull --ff-only
else
echo "Skipped: current directory is not a git repository"
fi
echo "[2/4] Pull and update containers"
docker compose pull web
docker compose up -d
echo "[3/4] Current container status"
docker compose ps
echo "[4/4] Recent logs"
docker compose logs --tail=50 web
echo
echo "Deployment complete. Default application URLs:"
echo " https://${HOST_DOMAIN:-localhost}"
echo " Backend port mapping: localhost:$APP_PORT -> container: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())
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
SOURCE_ENV="$PROJECT_ROOT/.env"
COMPOSE_SOURCE="$PROJECT_ROOT/docker-compose.yml"
BACKUP_TEMPLATE="$SCRIPT_DIR/backup_db.sh"
NGINX_TEMPLATE="$SCRIPT_DIR/nginx/moving-helper.nginx.template"
NGINX_SITE_NAME="moving-helper-nginx"
CRON_MARKER="# moving-helper-backup"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "This step requires root privileges, but sudo is not available: $*" >&2
exit 1
fi
}
escape_sed_replacement() {
printf '%s' "$1" | sed 's/[|&]/\\&/g'
}
render_template() {
src=$1
dst=$2
host_domain_escaped=$(escape_sed_replacement "$HOST_DOMAIN")
ssl_path_escaped=$(escape_sed_replacement "$SSL_PATH")
app_port_escaped=$(escape_sed_replacement "$APP_PORT")
app_dir_escaped=$(escape_sed_replacement "$APP_DIR")
backup_dir_escaped=$(escape_sed_replacement "$BACKUP_DIR")
sed \
-e "s|__HOST_DOMAIN__|$host_domain_escaped|g" \
-e "s|__SSL_PATH__|$ssl_path_escaped|g" \
-e "s|__APP_PORT__|$app_port_escaped|g" \
-e "s|__APP_DIR__|$app_dir_escaped|g" \
-e "s|__BACKUP_DIR__|$backup_dir_escaped|g" \
"$src" > "$dst"
}
if [ ! -f "$SOURCE_ENV" ]; then
echo "Missing $SOURCE_ENV" >&2
echo "Create it first: cp .env.example .env" >&2
exit 1
fi
require_command docker
require_command crontab
if ! docker compose version >/dev/null 2>&1; then
echo "The docker compose plugin is not available in the current environment" >&2
exit 1
fi
set -a
. "$SOURCE_ENV"
set +a
HOST_DOMAIN=${HOST_DOMAIN:-}
SSL_PATH=${SSL_PATH:-}
APP_DIR=${APP_DIR:-$HOME/.local/share/moving-helper}
BACKUP_DIR=${BACKUP_DIR:-$HOME/.local/backup/moving-helper}
APP_PORT=${APP_PORT:-10000}
DATA_DIR=${DATA_DIR:-./data}
if [ -z "$HOST_DOMAIN" ]; then
echo "HOST_DOMAIN is not configured" >&2
exit 1
fi
if [ -z "$SSL_PATH" ]; then
echo "SSL_PATH is not configured" >&2
exit 1
fi
mkdir -p "$APP_DIR" "$BACKUP_DIR" "$APP_DIR/logs"
case "$DATA_DIR" in
/*) mkdir -p "$DATA_DIR" ;;
*) mkdir -p "$APP_DIR/$DATA_DIR" ;;
esac
cp "$COMPOSE_SOURCE" "$APP_DIR/docker-compose.yml"
cp "$SOURCE_ENV" "$APP_DIR/.env"
rendered_backup=$(mktemp)
rendered_nginx=$(mktemp)
trap 'rm -f "$rendered_backup" "$rendered_nginx"' EXIT INT TERM
render_template "$BACKUP_TEMPLATE" "$rendered_backup"
install -m 0755 "$rendered_backup" "$APP_DIR/backup_db.sh"
render_template "$NGINX_TEMPLATE" "$rendered_nginx"
run_as_root install -d /etc/nginx/sites-available /etc/nginx/sites-enabled
run_as_root install -m 0644 "$rendered_nginx" "/etc/nginx/sites-available/$NGINX_SITE_NAME"
run_as_root ln -sfn "/etc/nginx/sites-available/$NGINX_SITE_NAME" "/etc/nginx/sites-enabled/$NGINX_SITE_NAME"
run_as_root nginx -t
if command -v systemctl >/dev/null 2>&1; then
run_as_root systemctl reload nginx
else
run_as_root service nginx reload
fi
(
cd "$APP_DIR"
docker compose pull web
docker compose up -d
)
cron_tmp=$(mktemp)
existing_cron=$(mktemp)
trap 'rm -f "$rendered_backup" "$rendered_nginx" "$cron_tmp" "$existing_cron"' EXIT INT TERM
crontab -l 2>/dev/null > "$existing_cron" || true
grep -v "moving-helper-backup" "$existing_cron" > "$cron_tmp" || true
printf '10 2 * * * %s/backup_db.sh >> %s/logs/backup.log 2>&1 %s\n' "$APP_DIR" "$APP_DIR" "$CRON_MARKER" >> "$cron_tmp"
crontab "$cron_tmp"
echo "Installation complete."
echo "- Application directory: $APP_DIR"
echo "- Backup directory: $BACKUP_DIR"
echo "- Nginx config: /etc/nginx/sites-available/$NGINX_SITE_NAME"
echo "- URL: https://$HOST_DOMAIN"
echo "- Scheduled backup: daily at 02:10 via the current user's crontab"
@@ -0,0 +1,29 @@
server {
listen 80;
server_name __HOST_DOMAIN__;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name __HOST_DOMAIN__;
ssl_certificate __SSL_PATH__/fullchain.pem;
ssl_certificate_key __SSL_PATH__/privkey.key;
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:__APP_PORT__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_read_timeout 300;
}
}
+149 -1
View File
@@ -2,7 +2,9 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
import app.db as db_module import app.db as db_module
import app.images as images_module
from PIL import Image from PIL import Image
from fastapi import UploadFile
from app.models import Box, Item, SubItem from app.models import Box, Item, SubItem
@@ -61,6 +63,19 @@ def make_image_upload(
return (filename, output.getvalue(), "image/png") 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): def read_jpeg_size(image_bytes):
with Image.open(BytesIO(image_bytes)) as image: with Image.open(BytesIO(image_bytes)) as image:
return image.format, image.size return image.format, image.size
@@ -88,6 +103,48 @@ def test_boxes_page_returns_200(client):
assert "箱子" in response.text assert "箱子" in response.text
def test_boxes_page_uses_relative_stylesheet_path(client):
response = client.get("/boxes")
assert response.status_code == 200
assert 'href="/static/style.css"' in response.text
assert "http://" not in response.text.split("/static/style.css")[0]
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): def test_can_create_box(client, db_session):
response = create_box(client, name="Kitchen Box") response = create_box(client, name="Kitchen Box")
@@ -162,6 +219,21 @@ def test_box_detail_returns_200_when_box_exists(client, db_session):
assert "Visible Box" in response.text 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): def test_can_create_regular_item_under_box(client, db_session):
box = Box(name="Main Box") box = Box(name="Main Box")
db_session.add(box) db_session.add(box)
@@ -355,6 +427,21 @@ def test_can_upload_image_for_box_and_process_it(client, db_session):
assert image_size == (1600, 800) 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): def test_can_upload_image_for_item(client, db_session):
box = Box(name="Main Box") box = Box(name="Main Box")
db_session.add(box) db_session.add(box)
@@ -572,6 +659,24 @@ def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client
assert updated_box.image_height is None 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): def test_can_search_box_by_name(client, db_session):
box = Box(name="冬季衣物箱") box = Box(name="冬季衣物箱")
db_session.add(box) db_session.add(box)
@@ -769,7 +874,9 @@ def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client
assert response.status_code == 200 assert response.status_code == 200
assert "Box" in response.text assert "Box" in response.text
assert "厨房箱" in response.text assert "厨房箱" in response.text
assert "compact-row" 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): def test_item_detail_page_renders_clear_hierarchy(client, db_session):
@@ -785,6 +892,47 @@ def test_item_detail_page_renders_clear_hierarchy(client, db_session):
assert "容器型 Item" in response.text assert "容器型 Item" in response.text
assert "书房箱" in response.text assert "书房箱" in response.text
assert "SubItem" 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): def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
+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