feat: add Stow prototype iOS app and Gin backend

This commit is contained in:
2026-10-02 21:23:31 +08:00
commit fe72c1ebd2
30 changed files with 3252 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.DS_Store
.idea/
DerivedData/
*.xcuserstate
xcuserdata/
*.ipa
*.app/
backend/stow-api
+311
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
# Stow Go API(开发中)
当前实现匿名会话、Item 创建/列表/详情/编辑/软删除,以及关键词搜索。使用 PostgreSQL 和 `pg_trgm` 建表;向量搜索需要服务器安装 `pgvector` 后再增加相应迁移。AI Worker、图片上传和云同步仍在开发。
数据库业务表统一使用 `stow_` 前缀,字段使用 `snake_case` 命名;业务表的主键 `id` 使用 `VARCHAR(36)` 存储 UUID 字符串,并由数据库检查格式。Go 结构体类型名不加 `Stow` 前缀,导出字段使用 Go 惯用的大驼峰写法。HTTP JSON 字段保持现有 `snake_case` 接口约定。
HTTP 层使用 Gin。所有响应统一为 `{"code":"ok","message":"success","data":...}`;失败时 `code` 为错误标识、`message` 为可读提示、`data` 为 `null`,同时返回对应的 HTTP 状态码。删除成功返回 HTTP 200 和同样的响应结构。
从 `backend/` 目录启动本地依赖与 API:
```sh
docker compose up -d
export DATABASE_URL='postgres://stow:stow_dev@localhost:5432/stow?sslmode=disable'
go run ./cmd/migrate
go run ./cmd/api
```
验证:
```sh
curl http://localhost:8080/v1/health
curl -X POST http://localhost:8080/v1/session/anonymous
```
所有 `/v1/items` 与 `/v1/search` 请求需要 `Authorization: Bearer <access_token>`。开发环境数据库密码只用于本地 Compose;生产部署需要单独配置私有数据库、TLS、对象存储和密钥管理。
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"stow/backend/internal/api"
"stow/backend/internal/store"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
logger.Error("DATABASE_URL is required")
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
dataStore, err := store.Open(ctx, databaseURL)
if err != nil {
logger.Error("database unavailable", "error", err)
os.Exit(1)
}
defer dataStore.Close()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{Addr: ":" + port, Handler: api.New(dataStore), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
logger.Info("stow api listening", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"database/sql"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
logger.Error("DATABASE_URL is required")
os.Exit(1)
}
directory := os.Getenv("MIGRATIONS_DIR")
if directory == "" {
directory = "migrations"
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := sql.Open("pgx", databaseURL)
if err != nil {
logger.Error("database open failed", "error", err)
os.Exit(1)
}
defer db.Close()
if err := migrate(ctx, db, directory, logger); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
}
func migrate(ctx context.Context, db *sql.DB, directory string, logger *slog.Logger) error {
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
var names []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS stow_schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
return err
}
for _, name := range names {
var applied bool
if err := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM stow_schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil {
return err
}
if applied {
continue
}
content, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(735160)`); err != nil {
tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO stow_schema_migrations (name) VALUES ($1)`, name); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
logger.Info("applied migration", "name", name)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: stow
POSTGRES_USER: stow
POSTGRES_PASSWORD: stow_dev
ports:
- "5432:5432"
volumes:
- stow_postgres:/var/lib/postgresql/data
volumes:
stow_postgres:
+44
View File
@@ -0,0 +1,44 @@
module stow/backend
go 1.26.2
require (
github.com/gin-gonic/gin v1.12.0
github.com/jackc/pgx/v5 v5.11.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+100
View File
@@ -0,0 +1,100 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+17
View File
@@ -0,0 +1,17 @@
package api
import "github.com/gin-gonic/gin"
type Response struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
func success(c *gin.Context, status int, data any) {
c.JSON(status, Response{Code: "ok", Message: "success", Data: data})
}
func failure(c *gin.Context, status int, code, message string) {
c.JSON(status, Response{Code: code, Message: message, Data: nil})
}
+292
View File
@@ -0,0 +1,292 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"stow/backend/internal/store"
)
type DataStore interface {
CreateAnonymousSession(context.Context) (string, string, error)
Authenticate(context.Context, string) (string, error)
CreateItem(context.Context, string, store.NewItem) (store.Item, error)
GetItem(context.Context, string, string) (store.Item, error)
ListItems(context.Context, string, int) ([]store.Item, error)
SearchItems(context.Context, string, string, int) ([]store.Item, error)
UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error)
DeleteItem(context.Context, string, string) error
}
type Server struct{ store DataStore }
func New(dataStore DataStore) http.Handler {
server := &Server{store: dataStore}
router := gin.New()
router.HandleMethodNotAllowed = true
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}))
_ = router.SetTrustedProxies(nil)
router.NoRoute(func(c *gin.Context) { failure(c, http.StatusNotFound, "not_found", "接口不存在") })
router.NoMethod(func(c *gin.Context) {
failure(c, http.StatusMethodNotAllowed, "method_not_allowed", "请求方法不支持")
})
v1 := router.Group("/v1")
v1.GET("/health", func(c *gin.Context) { success(c, http.StatusOK, gin.H{"status": "ok"}) })
v1.POST("/session/anonymous", server.anonymousSession)
authorized := v1.Group("")
authorized.Use(server.requireAuth())
authorized.POST("/items", server.createItem)
authorized.GET("/items", server.listItems)
authorized.GET("/items/:id", server.getItem)
authorized.PATCH("/items/:id", server.updateItem)
authorized.DELETE("/items/:id", server.deleteItem)
authorized.POST("/search", server.search)
return router
}
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(header[7:]) == "" {
failure(c, http.StatusUnauthorized, "missing_token", "请先创建匿名会话")
c.Abort()
return
}
userID, err := s.store.Authenticate(c.Request.Context(), strings.TrimSpace(header[7:]))
if errors.Is(err, sql.ErrNoRows) {
failure(c, http.StatusUnauthorized, "invalid_token", "会话无效或已过期")
c.Abort()
return
}
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
c.Abort()
return
}
c.Set("userID", userID)
c.Next()
}
}
func (s *Server) anonymousSession(c *gin.Context) {
userID, token, err := s.store.CreateAnonymousSession(c.Request.Context())
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "创建会话失败")
return
}
success(c, http.StatusCreated, gin.H{"user_id": userID, "access_token": token, "token_type": "Bearer"})
}
func (s *Server) createItem(c *gin.Context) {
var input store.NewItem
if !readJSON(c, &input) {
return
}
if message := validateNewItem(&input); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.CreateItem(c.Request.Context(), c.GetString("userID"), input)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "保存失败")
return
}
success(c, http.StatusCreated, item)
}
func (s *Server) listItems(c *gin.Context) {
items, err := s.store.ListItems(c.Request.Context(), c.GetString("userID"), parseLimit(c.Query("limit")))
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "读取失败")
return
}
success(c, http.StatusOK, gin.H{"items": items})
}
func (s *Server) getItem(c *gin.Context) {
item, err := s.store.GetItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) updateItem(c *gin.Context) {
var patch store.ItemPatch
if !readJSON(c, &patch) {
return
}
if message := validatePatch(patch); message != "" {
failure(c, http.StatusBadRequest, "invalid_item", message)
return
}
item, err := s.store.UpdateItem(c.Request.Context(), c.GetString("userID"), c.Param("id"), patch)
if !handleItemError(c, err) {
return
}
success(c, http.StatusOK, item)
}
func (s *Server) deleteItem(c *gin.Context) {
if !handleItemError(c, s.store.DeleteItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))) {
return
}
success(c, http.StatusOK, nil)
}
func (s *Server) search(c *gin.Context) {
var request struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if !readJSON(c, &request) {
return
}
request.Query = strings.TrimSpace(request.Query)
if request.Query == "" || len([]rune(request.Query)) > 200 {
failure(c, http.StatusBadRequest, "invalid_query", "搜索内容需为 1 到 200 字")
return
}
if request.Limit <= 0 {
request.Limit = 20
}
if request.Limit > 100 {
request.Limit = 100
}
items, err := s.store.SearchItems(c.Request.Context(), c.GetString("userID"), request.Query, request.Limit)
if err != nil {
failure(c, http.StatusInternalServerError, "server_error", "搜索失败")
return
}
success(c, http.StatusOK, gin.H{"items": items, "query": request.Query})
}
func handleItemError(c *gin.Context, err error) bool {
if err == nil {
return true
}
if errors.Is(err, store.ErrNotFound) {
failure(c, http.StatusNotFound, "not_found", "收藏不存在")
} else if errors.Is(err, store.ErrConflict) {
failure(c, http.StatusConflict, "conflict", "收藏已更新,请重试")
} else {
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
}
return false
}
func readJSON(c *gin.Context, destination any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
failure(c, http.StatusBadRequest, "invalid_json", "请求内容不是有效 JSON")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
failure(c, http.StatusBadRequest, "invalid_json", "请求只能包含一个 JSON 对象")
return false
}
return true
}
func parseLimit(raw string) int {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return 20
}
if limit > 100 {
return 100
}
return limit
}
func validateNewItem(input *store.NewItem) string {
input.Title = strings.TrimSpace(input.Title)
input.Summary = strings.TrimSpace(input.Summary)
if input.Title == "" || len([]rune(input.Title)) > 160 {
return "标题需为 1 到 160 字"
}
if len([]rune(input.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if input.PrimaryType == "" {
input.PrimaryType = "other"
}
if !validType(input.PrimaryType) {
return "无效的分类"
}
if !validSource(input.SourceType) {
return "无效的来源类型"
}
if input.SourceURL != nil {
parsed, err := url.Parse(*input.SourceURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "无效的来源链接"
}
}
if len(input.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range input.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
if input.CapturedAt != nil && input.CapturedAt.After(time.Now().Add(24*time.Hour)) {
return "收藏时间不能晚于明天"
}
return ""
}
func validatePatch(patch store.ItemPatch) string {
if patch.Title != nil && (strings.TrimSpace(*patch.Title) == "" || len([]rune(*patch.Title)) > 160) {
return "标题需为 1 到 160 字"
}
if patch.Summary != nil && len([]rune(*patch.Summary)) > 2000 {
return "摘要不能超过 2000 字"
}
if patch.PrimaryType != nil && !validType(*patch.PrimaryType) {
return "无效的分类"
}
if patch.Tags != nil {
if len(*patch.Tags) > 20 {
return "标签不能超过 20 个"
}
for _, tag := range *patch.Tags {
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
return "标签需为 1 到 40 字"
}
}
}
return ""
}
func validType(value string) bool {
switch value {
case "place", "product", "content", "event", "task", "document", "conversation", "other":
return true
}
return false
}
func validSource(value string) bool {
switch value {
case "screenshot", "manual_photo", "share_image", "share_url", "share_text", "manual_url", "manual_text":
return true
}
return false
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"stow/backend/internal/store"
)
type fakeStore struct {
created bool
lastUser string
lastItem store.NewItem
}
func (f *fakeStore) CreateAnonymousSession(context.Context) (string, string, error) {
return "user-1", "token-1", nil
}
func (f *fakeStore) Authenticate(_ context.Context, token string) (string, error) {
if token != "token-1" {
return "", sql.ErrNoRows
}
return "user-1", nil
}
func (f *fakeStore) CreateItem(_ context.Context, userID string, item store.NewItem) (store.Item, error) {
f.created, f.lastUser, f.lastItem = true, userID, item
return store.Item{ID: "item-1", SourceType: item.SourceType, PrimaryType: item.PrimaryType, Title: item.Title, Summary: item.Summary, Status: "ready", Tags: item.Tags, CreatedAt: time.Now()}, nil
}
func (f *fakeStore) GetItem(context.Context, string, string) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) ListItems(context.Context, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) SearchItems(context.Context, string, string, int) ([]store.Item, error) {
return []store.Item{}, nil
}
func (f *fakeStore) UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error) {
return store.Item{}, store.ErrNotFound
}
func (f *fakeStore) DeleteItem(context.Context, string, string) error { return store.ErrNotFound }
func TestItemsRequireSession(t *testing.T) {
fake := &fakeStore{}
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(`{"source_type":"manual_text","title":"咖啡店"}`))
response := httptest.NewRecorder()
New(fake).ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
if fake.created {
t.Fatal("item was created without authentication")
}
}
func TestCreateItemValidatesAndUsesAuthenticatedUser(t *testing.T) {
fake := &fakeStore{}
handler := New(fake)
for _, test := range []struct {
name, body string
want int
}{
{"invalid source", `{"source_type":"unknown","title":"咖啡店"}`, http.StatusBadRequest},
{"valid item", `{"source_type":"manual_text","title":"咖啡店","tags":["上海"]}`, http.StatusCreated},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(test.body))
request.Header.Set("Authorization", "Bearer token-1")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("status = %d, want %d: %s", response.Code, test.want, response.Body.String())
}
})
}
if !fake.created || fake.lastUser != "user-1" || fake.lastItem.Title != "咖啡店" {
t.Fatalf("wrong authenticated create: %+v", fake)
}
}
func TestAnonymousSessionReturnsToken(t *testing.T) {
response := httptest.NewRecorder()
New(&fakeStore{}).ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/session/anonymous", nil))
if response.Code != http.StatusCreated {
t.Fatalf("status = %d", response.Code)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "ok" || body.Data["access_token"] != "token-1" || body.Data["token_type"] != "Bearer" {
t.Fatalf("unexpected response: %v", body)
}
}
func TestAllResponsesUseEnvelope(t *testing.T) {
handler := New(&fakeStore{})
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodGet, "/v1/health", http.StatusOK},
{http.MethodGet, "/v1/items", http.StatusUnauthorized},
{http.MethodGet, "/missing", http.StatusNotFound},
{http.MethodPut, "/v1/health", http.StatusMethodNotAllowed},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: status %d, want %d", test.method, test.path, response.Code, test.want)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("%s %s: %v", test.method, test.path, err)
}
if body.Code == "" || body.Message == "" || body.Data == nil {
t.Fatalf("%s %s: invalid envelope: %s", test.method, test.path, response.Body.String())
}
}
}
+259
View File
@@ -0,0 +1,259 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
var ErrNotFound = errors.New("item not found")
var ErrConflict = errors.New("item changed during update")
type Store struct{ db *sql.DB }
func Open(ctx context.Context, databaseURL string) (*Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(12)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
type Item struct {
ID string `json:"id"`
SourceType string `json:"source_type"`
SourceURL *string `json:"source_url,omitempty"`
PrimaryType string `json:"primary_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tags []string `json:"tags"`
CapturedAt *time.Time `json:"captured_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
type NewItem struct {
SourceType string `json:"source_type"`
SourceURL *string `json:"source_url"`
PrimaryType string `json:"primary_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
CapturedAt *time.Time `json:"captured_at"`
}
type ItemPatch struct {
Title *string `json:"title"`
Summary *string `json:"summary"`
PrimaryType *string `json:"primary_type"`
Tags *[]string `json:"tags"`
}
func NewUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
s := hex.EncodeToString(b[:])
return fmt.Sprintf("%s-%s-%s-%s-%s", s[:8], s[8:12], s[12:16], s[16:20], s[20:]), nil
}
func (s *Store) CreateAnonymousSession(ctx context.Context) (userID, token string, err error) {
userID, err = NewUUID()
if err != nil {
return "", "", err
}
sessionID, err := NewUUID()
if err != nil {
return "", "", err
}
var secret [32]byte
if _, err = rand.Read(secret[:]); err != nil {
return "", "", err
}
token = base64.RawURLEncoding.EncodeToString(secret[:])
hash := sha256.Sum256([]byte(token))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", "", err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_users (id) VALUES ($1)`, userID); err != nil {
return "", "", err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_sessions (id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4)`, sessionID, userID, hash[:], time.Now().Add(365*24*time.Hour)); err != nil {
return "", "", err
}
if err = tx.Commit(); err != nil {
return "", "", err
}
return userID, token, nil
}
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
hash := sha256.Sum256([]byte(token))
var userID string
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM stow_sessions WHERE token_hash=$1 AND expires_at>now()`, hash[:]).Scan(&userID)
return userID, err
}
const itemColumns = `id, source_type, source_url, primary_type, title, summary, status, tags, captured_at, created_at, updated_at, version`
func scanItem(scanner interface{ Scan(...any) error }) (Item, error) {
var item Item
var sourceURL sql.NullString
var capturedAt sql.NullTime
var tagsJSON []byte
err := scanner.Scan(&item.ID, &item.SourceType, &sourceURL, &item.PrimaryType, &item.Title, &item.Summary, &item.Status, &tagsJSON, &capturedAt, &item.CreatedAt, &item.UpdatedAt, &item.Version)
if err != nil {
return Item{}, err
}
if sourceURL.Valid {
item.SourceURL = &sourceURL.String
}
if capturedAt.Valid {
item.CapturedAt = &capturedAt.Time
}
if err := json.Unmarshal(tagsJSON, &item.Tags); err != nil {
return Item{}, err
}
if item.Tags == nil {
item.Tags = []string{}
}
return item, nil
}
func searchText(item NewItem) string {
return strings.Join(append([]string{item.Title, item.Summary}, item.Tags...), " ")
}
func (s *Store) CreateItem(ctx context.Context, userID string, input NewItem) (Item, error) {
id, err := NewUUID()
if err != nil {
return Item{}, err
}
tags, err := json.Marshal(input.Tags)
if err != nil {
return Item{}, err
}
return scanItem(s.db.QueryRowContext(ctx, `
INSERT INTO stow_items (id,user_id,source_type,source_url,primary_type,title,summary,status,tags,search_text,captured_at,ready_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'ready',$8,$9,$10,now())
RETURNING `+itemColumns,
id, userID, input.SourceType, input.SourceURL, input.PrimaryType, input.Title, input.Summary, tags, searchText(input), input.CapturedAt))
}
func (s *Store) GetItem(ctx context.Context, userID, itemID string) (Item, error) {
item, err := scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID))
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
return item, err
}
func (s *Store) ListItems(ctx context.Context, userID string, limit int) ([]Item, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC,id DESC LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) SearchItems(ctx context.Context, userID, query string, limit int) ([]Item, error) {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
pattern := "%" + escaped + "%"
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items
WHERE user_id=$1 AND deleted_at IS NULL AND (search_text ILIKE $2 ESCAPE '\' OR title ILIKE $2 ESCAPE '\')
ORDER BY GREATEST(similarity(title,$3),similarity(search_text,$3)) DESC,created_at DESC LIMIT $4`, userID, pattern, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0)
for rows.Next() {
item, err := scanItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) UpdateItem(ctx context.Context, userID, itemID string, patch ItemPatch) (Item, error) {
current, err := s.GetItem(ctx, userID, itemID)
if err != nil {
return Item{}, err
}
if patch.Title != nil {
current.Title = *patch.Title
}
if patch.Summary != nil {
current.Summary = *patch.Summary
}
if patch.PrimaryType != nil {
current.PrimaryType = *patch.PrimaryType
}
if patch.Tags != nil {
current.Tags = *patch.Tags
}
tags, err := json.Marshal(current.Tags)
if err != nil {
return Item{}, err
}
newText := searchText(NewItem{Title: current.Title, Summary: current.Summary, Tags: current.Tags})
updated, err := scanItem(s.db.QueryRowContext(ctx, `UPDATE stow_items SET title=$3,summary=$4,primary_type=$5,tags=$6,search_text=$7,updated_at=now(),version=version+1
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL AND version=$8 RETURNING `+itemColumns,
itemID, userID, current.Title, current.Summary, current.PrimaryType, tags, newText, current.Version))
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrConflict
}
return updated, err
}
func (s *Store) DeleteItem(ctx context.Context, userID, itemID string) error {
result, err := s.db.ExecContext(ctx, `UPDATE stow_items SET deleted_at=now(),updated_at=now(),version=version+1 WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE stow_users (
id VARCHAR(36) PRIMARY KEY,
"isAnonymous" BOOLEAN NOT NULL DEFAULT TRUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE stow_sessions (
id VARCHAR(36) PRIMARY KEY,
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
"tokenHash" BYTEA NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"expiresAt" TIMESTAMPTZ NOT NULL
);
CREATE INDEX stow_sessions_userId_idx ON stow_sessions ("userId");
CREATE TABLE stow_items (
id VARCHAR(36) PRIMARY KEY,
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
"sourceType" VARCHAR(32) NOT NULL,
"sourceUrl" TEXT,
"primaryType" VARCHAR(32) NOT NULL DEFAULT 'other',
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'ready',
"ocrText" TEXT,
"searchText" TEXT NOT NULL DEFAULT '',
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
"capturedAt" TIMESTAMPTZ,
"readyAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 1
);
CREATE INDEX stow_items_userId_createdAt_idx ON stow_items ("userId", "createdAt" DESC) WHERE "deletedAt" IS NULL;
CREATE INDEX stow_items_userId_updatedAt_idx ON stow_items ("userId", "updatedAt", id);
CREATE INDEX stow_items_searchText_trgm_idx ON stow_items USING gin ("searchText" gin_trgm_ops) WHERE "deletedAt" IS NULL;
CREATE TABLE stow_assets (
id VARCHAR(36) PRIMARY KEY,
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
"objectKey" TEXT,
"thumbnailKey" TEXT,
"mimeType" VARCHAR(128),
width INTEGER,
height INTEGER,
"sizeBytes" BIGINT,
sha256 VARCHAR(64),
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX stow_assets_itemId_sha256_idx ON stow_assets ("itemId", sha256) WHERE sha256 IS NOT NULL;
CREATE TABLE stow_item_analyses (
id VARCHAR(36) PRIMARY KEY,
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
"pipelineVersion" VARCHAR(64) NOT NULL,
"promptVersion" VARCHAR(64),
"modelProvider" VARCHAR(64),
"modelName" VARCHAR(128),
"resultJson" JSONB NOT NULL,
confidence REAL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX stow_item_analyses_itemId_createdAt_idx ON stow_item_analyses ("itemId", "createdAt" DESC);
@@ -0,0 +1,10 @@
ALTER TABLE stow_users ADD CONSTRAINT stow_users_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_sessions ADD CONSTRAINT stow_sessions_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_items ADD CONSTRAINT stow_items_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_assets ADD CONSTRAINT stow_assets_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
ALTER TABLE stow_item_analyses ADD CONSTRAINT stow_item_analyses_id_uuid_check
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
@@ -0,0 +1,37 @@
ALTER TABLE stow_users RENAME COLUMN "isAnonymous" TO is_anonymous;
ALTER TABLE stow_users RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_users RENAME COLUMN "updatedAt" TO updated_at;
ALTER TABLE stow_sessions RENAME COLUMN "userId" TO user_id;
ALTER TABLE stow_sessions RENAME COLUMN "tokenHash" TO token_hash;
ALTER TABLE stow_sessions RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_sessions RENAME COLUMN "expiresAt" TO expires_at;
ALTER TABLE stow_items RENAME COLUMN "userId" TO user_id;
ALTER TABLE stow_items RENAME COLUMN "sourceType" TO source_type;
ALTER TABLE stow_items RENAME COLUMN "sourceUrl" TO source_url;
ALTER TABLE stow_items RENAME COLUMN "primaryType" TO primary_type;
ALTER TABLE stow_items RENAME COLUMN "ocrText" TO ocr_text;
ALTER TABLE stow_items RENAME COLUMN "searchText" TO search_text;
ALTER TABLE stow_items RENAME COLUMN "capturedAt" TO captured_at;
ALTER TABLE stow_items RENAME COLUMN "readyAt" TO ready_at;
ALTER TABLE stow_items RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_items RENAME COLUMN "updatedAt" TO updated_at;
ALTER TABLE stow_items RENAME COLUMN "deletedAt" TO deleted_at;
ALTER TABLE stow_assets RENAME COLUMN "itemId" TO item_id;
ALTER TABLE stow_assets RENAME COLUMN "objectKey" TO object_key;
ALTER TABLE stow_assets RENAME COLUMN "thumbnailKey" TO thumbnail_key;
ALTER TABLE stow_assets RENAME COLUMN "mimeType" TO mime_type;
ALTER TABLE stow_assets RENAME COLUMN "sizeBytes" TO size_bytes;
ALTER TABLE stow_assets RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_item_analyses RENAME COLUMN "itemId" TO item_id;
ALTER TABLE stow_item_analyses RENAME COLUMN "pipelineVersion" TO pipeline_version;
ALTER TABLE stow_item_analyses RENAME COLUMN "promptVersion" TO prompt_version;
ALTER TABLE stow_item_analyses RENAME COLUMN "modelProvider" TO model_provider;
ALTER TABLE stow_item_analyses RENAME COLUMN "modelName" TO model_name;
ALTER TABLE stow_item_analyses RENAME COLUMN "resultJson" TO result_json;
ALTER TABLE stow_item_analyses RENAME COLUMN "createdAt" TO created_at;
ALTER TABLE stow_schema_migrations RENAME COLUMN "appliedAt" TO applied_at;
+17
View File
@@ -0,0 +1,17 @@
# Stow(收好)iOS
## 启动与调试
1. 安装完整 Xcode,并在首次启动时下载 iOS 平台组件。
2. 双击 `Stow.xcodeproj`,在顶部选择 **Stow** scheme 和一台 iPhone 模拟器(iOS 17+)。
3. 按 **⌘R** 编译运行。若使用真机,先在 Target → Signing & Capabilities 选择自己的 Team,并把 `com.example.Stow` 改为自己的 Bundle Identifier。
4. 打开 App 的「我的 → 加入演示收藏」,检查首页、分类、搜索和详情。再点首页右上角 `+`,分别导入文本、链接、照片;退出并重新启动 App,确认内容仍在。
5. 需要单步调试时,可在 `LocalImportService.saveImage`(图片与 OCR)、`ImportSheet`(选图入口)或 `SearchView.results`(搜索过滤)设断点,再按 **⌘R**。
工程名和代码名为 **Stow**,主屏幕显示名为 **收好**。
当前实现的是第一段本地闭环:SwiftUI 四个 Tab、SwiftData 持久化、手动选择图片、最近截图扫描与选择、Vision 本地 OCR、保存链接或文本、分类浏览、关键词搜索,以及详情编辑和删除。导入图片会压缩到最长边 1800 px 后保存在 App 的 Documents 目录;重复图片按 SHA-256 识别,OCR 失败时仍会保存图片。
当前尚未接入照片库增量扫描游标、Share Extension、Go API、云端 AI、同步、账号和订阅。根目录的 `Stow.html` 是产品交互原型,iOS App 会逐步替换其中的模拟流程。
本环境只有 Xcode Command Line Tools,无法运行 `xcodebuild` 或 iOS 模拟器。已检查 Swift 源码语法与 Xcode 工程文件格式,仍需在完整 Xcode 环境中进行首次编译和模拟器验收。
+45
View File
@@ -0,0 +1,45 @@
import SwiftData
import SwiftUI
private enum StowTab: Hashable {
case home, collection, search, profile
}
struct RootView: View {
@Query(sort: \SavedItem.createdAt, order: .reverse) private var items: [SavedItem]
@State private var selectedTab: StowTab = .home
@State private var showingImport = false
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView(items: items, openImport: { showingImport = true }, openSearch: { selectedTab = .search })
}
.tabItem { Label("首页", systemImage: "house") }
.tag(StowTab.home)
NavigationStack {
CollectionView(items: items, openImport: { showingImport = true })
}
.tabItem { Label("收藏", systemImage: "square.stack") }
.tag(StowTab.collection)
NavigationStack {
SearchView(items: items)
}
.tabItem { Label("搜索", systemImage: "magnifyingglass") }
.tag(StowTab.search)
NavigationStack {
ProfileView(itemCount: items.count)
}
.tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(StowTab.profile)
}
.tint(StowStyle.forest)
.sheet(isPresented: $showingImport) {
ImportSheet()
.presentationDetents([.medium, .large])
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import SwiftData
import SwiftUI
@main
struct StowApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.modelContainer(for: SavedItem.self)
}
}
@@ -0,0 +1,87 @@
import SwiftUI
enum StowStyle {
static let forest = Color(red: 0.16, green: 0.39, blue: 0.27)
static let ink = Color(red: 0.13, green: 0.23, blue: 0.17)
static let muted = Color(red: 0.47, green: 0.57, blue: 0.49)
static let canvas = Color(red: 0.975, green: 0.973, blue: 0.955)
static let paleGreen = Color(red: 0.88, green: 0.94, blue: 0.87)
}
struct StowSectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.system(size: 19, weight: .bold))
.foregroundStyle(StowStyle.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ItemArtwork: View {
let item: SavedItem
var size: CGFloat = 70
var body: some View {
Group {
if let url = LocalImportService.imageURL(for: item),
let image = UIImage(contentsOfFile: url.path) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
ZStack {
LinearGradient(
colors: [StowStyle.paleGreen, Color(red: 0.66, green: 0.78, blue: 0.68)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: ItemCategory(rawValue: item.primaryType)?.symbol ?? "square.stack")
.font(.system(size: size * 0.33, weight: .light))
.foregroundStyle(StowStyle.forest)
}
}
}
.frame(width: size, height: size)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 13))
}
}
struct ItemRow: View {
let item: SavedItem
var body: some View {
HStack(spacing: 13) {
ItemArtwork(item: item)
VStack(alignment: .leading, spacing: 5) {
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(StowStyle.ink)
.lineLimit(1)
Text(item.summary.isEmpty ? ItemCategory(rawValue: item.primaryType)?.title ?? "收藏" : item.summary)
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
.lineLimit(1)
Text("\(sourceName) · \(item.createdAt.formatted(date: .abbreviated, time: .omitted))")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted.opacity(0.8))
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted.opacity(0.6))
}
.padding(9)
.background(.white, in: RoundedRectangle(cornerRadius: 17))
}
private var sourceName: String {
switch item.sourceType {
case "manual_photo", "screenshot": "图片"
case "manual_url", "share_url": "链接"
default: "文本"
}
}
}
@@ -0,0 +1,87 @@
import SwiftUI
struct CollectionView: View {
let items: [SavedItem]
var initialGroup: String? = nil
let openImport: () -> Void
@State private var category: ItemCategory = .all
private var filteredItems: [SavedItem] {
items.filter { item in
(category == .all || item.primaryType == category.rawValue) &&
(initialGroup == nil || item.tags.contains(initialGroup!))
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("YOUR COLLECTION")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text(initialGroup == nil ? "我的收藏" : groupTitle)
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
Spacer()
Button(action: openImport) {
Image(systemName: "plus")
.frame(width: 38, height: 38)
.background(.white, in: Circle())
}
.accessibilityLabel("添加收藏")
}
Text(initialGroup == nil ? "一共收好了 \(items.count) 条内容。" : "收好自动放在一起的内容。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(ItemCategory.allCases) { option in
Button { category = option } label: {
Text(option.title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(category == option ? Color.white : StowStyle.muted)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(category == option ? StowStyle.forest : Color.white, in: Capsule())
}
}
}
}
Text("\(filteredItems.count) 条收藏")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
if filteredItems.isEmpty {
ContentUnavailableView("这里还没有内容", systemImage: "square.stack", description: Text("试试其他分类,或添加一条新收藏。"))
} else {
LazyVStack(spacing: 10) {
ForEach(filteredItems) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(initialGroup == nil ? .hidden : .visible, for: .navigationBar)
}
private var groupTitle: String {
switch initialGroup {
case "想吃": "想吃的"
case "想去": "想去的"
case "想买": "想买的"
default: "值得再看"
}
}
}
@@ -0,0 +1,130 @@
import SwiftUI
struct HomeView: View {
let items: [SavedItem]
let openImport: () -> Void
let openSearch: () -> Void
private let groups: [(String, String, String, Color)] = [
("想吃的", "想吃", "fork.knife", Color(red: 0.96, green: 0.89, blue: 0.83)),
("想去的", "想去", "mappin", Color(red: 0.87, green: 0.93, blue: 0.87)),
("想买的", "想买", "bag", Color(red: 0.92, green: 0.90, blue: 0.95)),
("值得再看", "值得再看", "books.vertical", Color(red: 0.96, green: 0.93, blue: 0.83))
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack {
Label("收好", systemImage: "square.stack.3d.up.fill")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(StowStyle.forest)
Spacer()
Button(action: openImport) {
Image(systemName: "plus")
.font(.system(size: 17, weight: .semibold))
.frame(width: 38, height: 38)
.background(.white, in: Circle())
}
.accessibilityLabel("添加收藏")
}
VStack(alignment: .leading, spacing: 8) {
Text(Date.now.formatted(.dateTime.month(.wide).day().weekday(.wide)))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted)
Text("看过的好东西,\n都在这里。")
.font(.system(size: 31, weight: .bold))
.tracking(-1)
.foregroundStyle(StowStyle.ink)
Text("顺手收好,想找时就找得到。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
}
Button(action: openSearch) {
HStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 19))
.foregroundStyle(StowStyle.forest)
Text("找一下之前收藏过的东西…")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
Spacer()
}
.padding(17)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
}
NavigationLink {
ScreenshotScanView()
} label: {
HStack(spacing: 14) {
Image(systemName: "photo.stack")
.font(.system(size: 27))
.frame(width: 48, height: 48)
.background(.white.opacity(0.17), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 5) {
Text("有新截图想整理?")
.font(.system(size: 16, weight: .bold))
Text("查看最近截图,只导入你选中的内容")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
Image(systemName: "arrow.right")
}
.foregroundStyle(.white)
.padding(17)
.background(StowStyle.forest, in: RoundedRectangle(cornerRadius: 18))
}
VStack(alignment: .leading, spacing: 14) {
StowSectionHeader(title: "为你整理")
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 10) {
ForEach(groups.indices, id: \.self) { index in
let group = groups[index]
NavigationLink {
CollectionView(items: items, initialGroup: group.1, openImport: openImport)
} label: {
VStack(alignment: .leading, spacing: 11) {
HStack {
Image(systemName: group.2)
.font(.system(size: 22))
Spacer()
Text("\(items.filter { $0.tags.contains(group.1) }.count)")
.font(.system(size: 18, weight: .bold))
}
Text(group.0)
.font(.system(size: 15, weight: .semibold))
}
.foregroundStyle(StowStyle.ink)
.padding(17)
.frame(maxWidth: .infinity, minHeight: 112, alignment: .leading)
.background(group.3, in: RoundedRectangle(cornerRadius: 17))
}
}
}
}
VStack(alignment: .leading, spacing: 12) {
StowSectionHeader(title: "刚刚收好")
if items.isEmpty {
Button(action: openImport) {
ContentUnavailableView("还没有收藏", systemImage: "square.and.arrow.down", description: Text("先添加一张图片、一条链接或一段文字。"))
}
.buttonStyle(.plain)
} else {
ForEach(items.prefix(5)) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
}
}
@@ -0,0 +1,122 @@
import PhotosUI
import SwiftData
import SwiftUI
struct ImportSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var selectedPhoto: PhotosPickerItem?
@State private var mode: ImportMode = .choices
@State private var text = ""
@State private var url = ""
@State private var isWorking = false
@State private var errorMessage: String?
private enum ImportMode { case choices, text, url }
var body: some View {
NavigationStack {
Group {
switch mode {
case .choices: choices
case .text: textForm
case .url: urlForm
}
}
.navigationTitle("把好东西收进来")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) { Button("关闭") { dismiss() } }
}
}
.tint(StowStyle.forest)
.onChange(of: selectedPhoto) { _, photo in
guard let photo else { return }
Task {
isWorking = true
defer { isWorking = false }
do {
guard let data = try await photo.loadTransferable(type: Data.self) else { throw ImportError.invalidImage }
try await LocalImportService.saveImage(data, in: context)
dismiss()
} catch { errorMessage = error.localizedDescription }
}
}
.alert("无法收好", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
.overlay { if isWorking { ProgressView("正在识别并保存…").padding(24).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } }
}
private var choices: some View {
VStack(alignment: .leading, spacing: 13) {
Text("选一种最顺手的方式,剩下的交给收好。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
.padding(.bottom, 10)
PhotosPicker(selection: $selectedPhoto, matching: .images) {
choice("选择截图或照片", "从相册中手动选取", "photo.on.rectangle")
}
NavigationLink {
ScreenshotScanView()
} label: {
choice("整理最近截图", "选取最近的截图批量收好", "rectangle.stack")
}
Button { mode = .url } label: { choice("保存链接", "文章、商品、攻略都可以", "link") }
Button { mode = .text } label: { choice("保存文本", "把一段文字收起来", "text.alignleft") }
Spacer()
Text("目前只处理你主动选择的内容,数据保存在本机。")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted)
}
.padding(22)
.background(StowStyle.canvas)
}
private var textForm: some View {
Form {
Section("内容") {
TextField("粘贴想记住的内容…", text: $text, axis: .vertical)
.lineLimit(5...10)
}
Section {
Button("收好这段文字") { save { try LocalImportService.saveText(text, in: context) } }
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private var urlForm: some View {
Form {
Section("链接地址") { TextField("https://example.com", text: $url).textInputAutocapitalization(.never).keyboardType(.URL).autocorrectionDisabled() }
Section {
Button("收好这个链接") { save { try LocalImportService.saveURL(url, in: context) } }
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private func choice(_ title: String, _ subtitle: String, _ symbol: String) -> some View {
HStack(spacing: 14) {
Image(systemName: symbol)
.font(.system(size: 20))
.frame(width: 42, height: 42)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 15, weight: .semibold))
Text(subtitle).font(.system(size: 12)).foregroundStyle(StowStyle.muted)
}
Spacer()
Image(systemName: "chevron.right").font(.system(size: 11))
}
.foregroundStyle(StowStyle.ink)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
}
private func save(_ operation: () throws -> Void) {
do { try operation(); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
@@ -0,0 +1,210 @@
import Photos
import SwiftData
import SwiftUI
import UIKit
struct ScreenshotScanView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var candidates: [ScreenshotCandidate] = []
@State private var selectedIDs: Set<String> = []
@State private var phase: Phase = .introduction
@State private var importedCount = 0
@State private var failedCount = 0
private enum Phase { case introduction, loading, selection, importing, complete, denied }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch phase {
case .introduction: introduction
case .loading: loading
case .selection: selection
case .importing: importing
case .complete: completion
case .denied: denied
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(StowStyle.canvas)
.navigationTitle("整理截图")
.navigationBarTitleDisplayMode(.inline)
}
private var introduction: some View {
VStack(alignment: .leading, spacing: 16) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 39))
.foregroundStyle(StowStyle.forest)
.padding(20)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 20))
Text("帮你整理最近的截图")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(StowStyle.ink)
Text("收好会找出照片库里的截图,只展示最近 30 张,由你确认哪些需要导入。你的内容不会公开。")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
.lineSpacing(5)
Button("查看最近截图") { Task { await loadScreenshots() } }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
Text("也可以返回,使用系统照片选择器手动挑选。")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.padding(.top, 34)
}
private var loading: some View {
ProgressView("正在查找截图…")
.frame(maxWidth: .infinity, minHeight: 220)
}
private var selection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("发现 \(candidates.count) 张最近的截图")
.font(.system(size: 23, weight: .bold))
.foregroundStyle(StowStyle.ink)
Text("已选择 \(selectedIDs.count) 张 · 只处理你确认的内容")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
if candidates.isEmpty {
ContentUnavailableView("没有发现截图", systemImage: "photo", description: Text("你仍然可以使用手动选择照片。"))
} else {
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 12) {
ForEach(candidates) { candidate in
Button { toggle(candidate.id) } label: {
ScreenshotTile(candidate: candidate, selected: selectedIDs.contains(candidate.id))
}
.buttonStyle(.plain)
}
}
Button("收好这 \(selectedIDs.count) 张") { Task { await importSelected() } }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
.disabled(selectedIDs.isEmpty)
.frame(maxWidth: .infinity)
}
}
}
private var importing: some View {
VStack(spacing: 14) {
ProgressView()
Text("正在识别并保存截图…")
.font(.system(size: 15, weight: .semibold))
Text("已收好 \(importedCount) 张。OCR 失败时,图片仍会保存。")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.frame(maxWidth: .infinity, minHeight: 240)
}
private var completion: some View {
VStack(alignment: .leading, spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 52))
.foregroundStyle(StowStyle.forest)
Text("\(importedCount) 张截图已经收好")
.font(.system(size: 25, weight: .bold))
if failedCount > 0 {
Text("另有 \(failedCount) 张未能读取,可以稍后重试。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
}
Button("完成") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
}
.padding(.top, 40)
}
private var denied: some View {
VStack(alignment: .leading, spacing: 16) {
Text("还不能查看截图")
.font(.system(size: 25, weight: .bold))
Text("请到系统设置中允许收好访问照片,或返回使用手动选择照片。")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
if let settings = URL(string: UIApplication.openSettingsURLString) {
Link("打开设置", destination: settings)
}
}
.padding(.top, 40)
}
private func toggle(_ id: String) {
if selectedIDs.contains(id) { selectedIDs.remove(id) }
else { selectedIDs.insert(id) }
}
private func loadScreenshots() async {
phase = .loading
let access = await ScreenshotLibraryService.requestAccess()
guard access == .authorized || access == .limited else { phase = .denied; return }
do {
let recent = ScreenshotLibraryService.recentScreenshots()
candidates = try recent.filter { try !LocalImportService.hasImported($0.id, in: context) }
selectedIDs = Set(candidates.prefix(5).map(\.id))
phase = .selection
} catch {
phase = .denied
}
}
private func importSelected() async {
phase = .importing
importedCount = 0
failedCount = 0
for candidate in candidates where selectedIDs.contains(candidate.id) {
do {
let data = try await ScreenshotLibraryService.imageData(for: candidate.asset)
let saved = try await LocalImportService.saveImage(
data,
sourceType: "screenshot",
photoAssetIdentifier: candidate.id,
capturedAt: candidate.capturedAt,
in: context
)
if saved { importedCount += 1 }
} catch {
failedCount += 1
}
}
phase = .complete
}
}
private struct ScreenshotTile: View {
let candidate: ScreenshotCandidate
let selected: Bool
@State private var thumbnail: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
Group {
if let thumbnail {
Image(uiImage: thumbnail).resizable().scaledToFill()
} else {
Rectangle().fill(StowStyle.paleGreen)
.overlay { Image(systemName: "photo").foregroundStyle(StowStyle.muted) }
}
}
.frame(height: 170)
.frame(maxWidth: .infinity)
.clipped()
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 24))
.foregroundStyle(selected ? StowStyle.forest : .white)
.padding(9)
}
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).stroke(selected ? StowStyle.forest : .clear, lineWidth: 2) }
.task(id: candidate.id) { thumbnail = await ScreenshotLibraryService.thumbnail(for: candidate.asset) }
}
}
@@ -0,0 +1,163 @@
import SwiftData
import SwiftUI
struct ItemDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let item: SavedItem
@State private var showingEdit = false
@State private var showingDelete = false
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 23) {
ItemArtwork(item: item, size: 270)
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 7) {
Text((ItemCategory(rawValue: item.primaryType)?.title ?? "其他") + " · " + item.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted)
Text(item.title)
.font(.system(size: 27, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
VStack(alignment: .leading, spacing: 10) {
Label("收好帮你整理", systemImage: "sparkle")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.forest)
Text(item.summary.isEmpty ? "这条内容已收好。" : item.summary)
.font(.system(size: 14))
.foregroundStyle(StowStyle.ink)
.lineSpacing(5)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
if !item.tags.isEmpty {
VStack(alignment: .leading, spacing: 12) {
StowSectionHeader(title: "标签")
FlowTags(tags: item.tags)
}
}
VStack(alignment: .leading, spacing: 10) {
StowSectionHeader(title: "原始内容")
Text(item.sourceType == "manual_photo" ? "手动选择的图片" : item.sourceType == "manual_url" ? "手动保存的链接" : "手动保存的文本")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
if let sourceURL = item.sourceURL, let url = URL(string: sourceURL) {
Link(destination: url) {
Label("打开原链接", systemImage: "arrow.up.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.forest)
}
}
}
HStack(spacing: 10) {
Button { showingEdit = true } label: {
Label("编辑整理结果", systemImage: "pencil")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(role: .destructive) { showingDelete = true } label: {
Image(systemName: "trash")
}
.buttonStyle(.bordered)
.accessibilityLabel("删除收藏")
}
.padding(.top, 10)
}
.padding(22)
}
.background(StowStyle.canvas)
.navigationTitle("收藏详情")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingEdit) { EditItemSheet(item: item) }
.confirmationDialog("删除这条收藏?", isPresented: $showingDelete) {
Button("删除", role: .destructive) {
do { try LocalImportService.delete(item, in: context); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
.alert("操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}
private struct FlowTags: View {
let tags: [String]
var body: some View {
ViewThatFits(in: .horizontal) {
HStack { tagViews }
VStack(alignment: .leading) { tagViews }
}
}
@ViewBuilder private var tagViews: some View {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12))
.foregroundStyle(StowStyle.forest)
.padding(.horizontal, 11)
.padding(.vertical, 8)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 8))
}
}
}
private struct EditItemSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let item: SavedItem
@State private var title: String
@State private var summary: String
@State private var tags: String
@State private var errorMessage: String?
init(item: SavedItem) {
self.item = item
_title = State(initialValue: item.title)
_summary = State(initialValue: item.summary)
_tags = State(initialValue: item.tags.joined(separator: ","))
}
var body: some View {
NavigationStack {
Form {
TextField("标题", text: $title)
TextField("摘要", text: $summary, axis: .vertical)
.lineLimit(3...6)
TextField("标签,用逗号分隔", text: $tags)
}
.navigationTitle("编辑收藏")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "标题不能为空。"; return
}
item.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
item.summary = summary.trimmingCharacters(in: .whitespacesAndNewlines)
item.tags = tags.split(whereSeparator: { $0 == "," || $0 == "," }).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
do { try context.save(); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
}
.alert("无法保存", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}
}
@@ -0,0 +1,98 @@
import SwiftData
import SwiftUI
struct ProfileView: View {
@Environment(\.modelContext) private var context
let itemCount: Int
@State private var debugMessage: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 23) {
VStack(alignment: .leading, spacing: 5) {
Text("YOUR SPACE")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text("我的")
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
HStack(spacing: 14) {
Image(systemName: "square.stack.3d.up.fill")
.font(.system(size: 25))
.foregroundStyle(StowStyle.forest)
.frame(width: 50, height: 50)
.background(.white.opacity(0.6), in: RoundedRectangle(cornerRadius: 15))
VStack(alignment: .leading, spacing: 4) {
Text("好东西,都收好了")
.font(.system(size: 15, weight: .semibold))
Text("本地体验 · 无需登录")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
HStack {
VStack(alignment: .leading) {
Text("\(itemCount)").font(.system(size: 26, weight: .bold)).foregroundStyle(StowStyle.forest)
Text("已收好").font(.system(size: 12)).foregroundStyle(StowStyle.muted)
}
Spacer()
Text("当前内容仅保存在这台设备")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.padding(18)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
VStack(alignment: .leading, spacing: 8) {
StowSectionHeader(title: "关于数据")
Text("你主动保存的图片会存入 App 的本地文件夹,收藏信息保存在本地数据库。当前版本尚未接入账号、同步和云端 AI。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
.lineSpacing(5)
}
#if DEBUG
VStack(alignment: .leading, spacing: 10) {
StowSectionHeader(title: "开发调试")
Button("加入演示收藏") { addDebugItems() }
.buttonStyle(.bordered)
Text("用于检查首页、分类、搜索和详情。正式构建不会显示此入口。")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted)
}
#endif
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
.alert("调试数据", isPresented: Binding(get: { debugMessage != nil }, set: { if !$0 { debugMessage = nil } })) {
Button("知道了", role: .cancel) { debugMessage = nil }
} message: { Text(debugMessage ?? "") }
}
#if DEBUG
private func addDebugItems() {
do {
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.sourceType == "debug" }) {
debugMessage = "演示收藏已经加入。"
return
}
context.insert(SavedItem(title: "上海静安的咖啡店", summary: "周末想去的咖啡店,推荐 Dirty。", primaryType: "place", sourceType: "debug", tags: ["上海", "静安", "咖啡", "想去", "想吃"]))
context.insert(SavedItem(title: "黑色机械键盘", summary: "收藏的一款黑色机械键盘。", primaryType: "product", sourceType: "debug", tags: ["黑色", "键盘", "想买"]))
context.insert(SavedItem(title: "杭州旅行攻略", summary: "周末两日游,包含西湖和灵隐寺。", primaryType: "content", sourceType: "debug", tags: ["杭州", "旅行", "值得再看"]))
try context.save()
debugMessage = "已加入 3 条演示收藏。"
} catch {
debugMessage = error.localizedDescription
}
}
#endif
}
@@ -0,0 +1,94 @@
import SwiftUI
struct SearchView: View {
let items: [SavedItem]
@State private var query = ""
private let suggestions = ["黑色键盘", "上海的咖啡店", "杭州旅行攻略", "AI Agent 文章"]
private var results: [SavedItem] {
let value = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !value.isEmpty else { return [] }
let knownTerms = ["黑色", "键盘", "上海", "咖啡", "杭州", "旅行", "攻略", "ai agent", "文章", "餐厅", "展览", "商品"]
let recognized = knownTerms.filter(value.contains)
let terms = recognized.isEmpty
? value.split(whereSeparator: { $0.isWhitespace || $0.isPunctuation }).map(String.init)
: recognized
return items.filter { item in
if value.contains("上个月") {
let daysAgo = Date.now.timeIntervalSince(item.createdAt) / 86400
guard (28.0...62.0).contains(daysAgo) else { return false }
}
let text = ([item.title, item.summary, item.ocrText ?? ""] + item.tags)
.joined(separator: " ").lowercased()
return terms.allSatisfy(text.contains)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
VStack(alignment: .leading, spacing: 5) {
Text("FIND IT AGAIN")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text("想找什么?")
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(StowStyle.forest)
TextField("输入标题、地点或关键词", text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !query.isEmpty {
Button { query = "" } label: { Image(systemName: "xmark.circle.fill") }
.foregroundStyle(StowStyle.muted)
}
}
.padding(15)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
if query.isEmpty {
VStack(alignment: .leading, spacing: 14) {
StowSectionHeader(title: "试着这样找")
ForEach(suggestions, id: \.self) { suggestion in
Button { query = suggestion } label: {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(StowStyle.forest)
Text(suggestion)
.foregroundStyle(StowStyle.ink)
Spacer()
Image(systemName: "arrow.up.left")
.foregroundStyle(StowStyle.muted)
}
.font(.system(size: 13))
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 13))
}
}
}
} else if results.isEmpty {
ContentUnavailableView.search(text: query)
} else {
VStack(alignment: .leading, spacing: 12) {
Text("找到 \(results.count) 条收藏")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.ink)
ForEach(results) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
}
}
+83
View File
@@ -0,0 +1,83 @@
import Foundation
import SwiftData
@Model
final class SavedItem {
@Attribute(.unique) var id: UUID
var title: String
var summary: String
var primaryType: String
var sourceType: String
var tags: [String]
var sourceURL: String?
var imageFilename: String?
var photoAssetIdentifier: String?
var imageSHA256: String?
var ocrText: String?
var capturedAt: Date
var createdAt: Date
init(
id: UUID = UUID(),
title: String,
summary: String = "",
primaryType: String = "other",
sourceType: String,
tags: [String] = [],
sourceURL: String? = nil,
imageFilename: String? = nil,
photoAssetIdentifier: String? = nil,
imageSHA256: String? = nil,
ocrText: String? = nil,
capturedAt: Date = .now,
createdAt: Date = .now
) {
self.id = id
self.title = title
self.summary = summary
self.primaryType = primaryType
self.sourceType = sourceType
self.tags = tags
self.sourceURL = sourceURL
self.imageFilename = imageFilename
self.photoAssetIdentifier = photoAssetIdentifier
self.imageSHA256 = imageSHA256
self.ocrText = ocrText
self.capturedAt = capturedAt
self.createdAt = createdAt
}
}
enum ItemCategory: String, CaseIterable, Identifiable {
case all, place, product, content, event, task, document, conversation, other
var id: String { rawValue }
var title: String {
switch self {
case .all: "全部"
case .place: "地点"
case .product: "商品"
case .content: "内容"
case .event: "活动"
case .task: "待办"
case .document: "资料"
case .conversation: "聊天"
case .other: "其他"
}
}
var symbol: String {
switch self {
case .all: "square.grid.2x2"
case .place: "mappin"
case .product: "bag"
case .content: "text.book.closed"
case .event: "calendar"
case .task: "checkmark.circle"
case .document: "doc.text"
case .conversation: "bubble.left.and.bubble.right"
case .other: "square.stack"
}
}
}
@@ -0,0 +1,159 @@
import CryptoKit
import Foundation
import SwiftData
import UIKit
import Vision
@MainActor
enum LocalImportService {
static func saveText(_ text: String, in context: ModelContext) throws {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
let title = String(value.prefix(34))
let item = SavedItem(
title: title,
summary: value,
primaryType: category(for: value).rawValue,
sourceType: "manual_text",
tags: tags(for: value)
)
context.insert(item)
try context.save()
}
static func saveURL(_ urlText: String, in context: ModelContext) throws {
let value = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: value), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
throw ImportError.invalidURL
}
let title = url.host?.replacingOccurrences(of: "www.", with: "") ?? "网页链接"
let item = SavedItem(
title: title,
summary: value,
primaryType: "content",
sourceType: "manual_url",
tags: ["链接"],
sourceURL: value
)
context.insert(item)
try context.save()
}
@discardableResult
static func saveImage(
_ data: Data,
sourceType: String = "manual_photo",
photoAssetIdentifier: String? = nil,
capturedAt: Date = .now,
in context: ModelContext
) async throws -> Bool {
if let photoAssetIdentifier, try hasImported(photoAssetIdentifier, in: context) { return false }
guard let image = UIImage(data: data) else { throw ImportError.invalidImage }
let id = UUID()
let filename = "\(id.uuidString).jpg"
let folder = try imageFolder()
let destination = folder.appendingPathComponent(filename)
let preparedImage = resized(image, maxDimension: 1800)
guard let jpeg = preparedImage.jpegData(compressionQuality: 0.82) else { throw ImportError.invalidImage }
let fingerprint = SHA256.hash(data: jpeg).map { String(format: "%02x", $0) }.joined()
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.imageSHA256 == fingerprint }) { return false }
try jpeg.write(to: destination, options: .atomic)
do {
// The original selection remains saved even when OCR cannot read it.
let ocr = (try? await recognizeText(in: preparedImage)) ?? ""
let firstLine = ocr.split(separator: "\n").first.map(String.init) ?? "新图片"
let text = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
let item = SavedItem(
id: id,
title: String(text.prefix(42)).isEmpty ? "新图片" : String(text.prefix(42)),
summary: String(ocr.prefix(180)),
primaryType: category(for: ocr).rawValue,
sourceType: sourceType,
tags: tags(for: ocr),
imageFilename: filename,
photoAssetIdentifier: photoAssetIdentifier,
imageSHA256: fingerprint,
ocrText: ocr,
capturedAt: capturedAt
)
context.insert(item)
try context.save()
return true
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
nonisolated static func imageURL(for item: SavedItem) -> URL? {
guard let filename = item.imageFilename else { return nil }
return try? imageFolder().appendingPathComponent(filename)
}
static func delete(_ item: SavedItem, in context: ModelContext) throws {
let fileURL = imageURL(for: item)
context.delete(item)
try context.save()
if let fileURL { try? FileManager.default.removeItem(at: fileURL) }
}
static func hasImported(_ identifier: String, in context: ModelContext) throws -> Bool {
try context.fetch(FetchDescriptor<SavedItem>()).contains { $0.photoAssetIdentifier == identifier }
}
nonisolated private static func imageFolder() throws -> URL {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let folder = documents.appendingPathComponent("SavedImages", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
return folder
}
private static func recognizeText(in image: UIImage) async throws -> String {
guard let cgImage = image.cgImage else { return "" }
return try await Task.detached(priority: .userInitiated) {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["zh-Hans", "en-US"]
request.usesLanguageCorrection = true
try VNImageRequestHandler(cgImage: cgImage).perform([request])
return (request.results ?? [])
.compactMap { $0.topCandidates(1).first?.string }
.joined(separator: "\n")
}.value
}
private static func resized(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let longestSide = max(image.size.width, image.size.height)
guard longestSide > maxDimension else { return image }
let scale = maxDimension / longestSide
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
return UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
private static func category(for text: String) -> ItemCategory {
if ["咖啡", "餐厅", "酒店", "地址", "景点"].contains(where: text.contains) { return .place }
if ["商品", "价格", "购买", "键盘", "耳机"].contains(where: text.contains) { return .product }
if ["展览", "演出", "活动", "门票"].contains(where: text.contains) { return .event }
return .content
}
private static func tags(for text: String) -> [String] {
let words = ["咖啡", "餐厅", "旅行", "上海", "杭州", "商品", "键盘", "展览", "AI"]
return words.filter(text.contains).prefix(4).map { $0 }
}
}
enum ImportError: LocalizedError {
case invalidURL
case invalidImage
var errorDescription: String? {
switch self {
case .invalidURL: "请输入有效的 http 或 https 链接。"
case .invalidImage: "无法读取这张图片。"
}
}
}
@@ -0,0 +1,71 @@
import Photos
import UIKit
struct ScreenshotCandidate: Identifiable {
let id: String
let asset: PHAsset
let capturedAt: Date
}
@MainActor
enum ScreenshotLibraryService {
static func requestAccess() async -> PHAuthorizationStatus {
let current = PHPhotoLibrary.authorizationStatus(for: .readWrite)
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { result in
continuation.resume(returning: result)
}
}
}
static func recentScreenshots(limit: Int = 30) -> [ScreenshotCandidate] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let assets = PHAsset.fetchAssets(with: .image, options: options)
var screenshots: [ScreenshotCandidate] = []
for index in 0..<assets.count {
let asset = assets.object(at: index)
guard asset.mediaSubtypes.contains(.photoScreenshot) else { continue }
screenshots.append(ScreenshotCandidate(
id: asset.localIdentifier,
asset: asset,
capturedAt: asset.creationDate ?? .now
))
if screenshots.count >= limit { break }
}
return screenshots
}
static func thumbnail(for asset: PHAsset) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.resizeMode = .fast
options.isNetworkAccessAllowed = false
PHImageManager.default().requestImage(
for: asset,
targetSize: CGSize(width: 300, height: 300),
contentMode: .aspectFill,
options: options
) { image, _ in
continuation.resume(returning: image)
}
}
}
static func imageData(for asset: PHAsset) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: ImportError.invalidImage)
}
}
}
}
}
+416
View File
@@ -0,0 +1,416 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
3362B8F6FEB9187AF06C2973 /* file:Sources/App/RootView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "RootView.swift";
sourceTree = "<group>";
};
E8EB797FC7B931792536B82F /* build:Sources/App/RootView.swift */ = {
isa = PBXBuildFile;
fileRef = 3362B8F6FEB9187AF06C2973 /* RootView.swift */;
};
09FA9998D53C167BA164487C /* file:Sources/App/StowApp.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "StowApp.swift";
sourceTree = "<group>";
};
864B5FF1FEBFBD45DF72BC12 /* build:Sources/App/StowApp.swift */ = {
isa = PBXBuildFile;
fileRef = 09FA9998D53C167BA164487C /* StowApp.swift */;
};
72483B00A6425B600C442BC9 /* file:Sources/DesignSystem/StowStyle.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "StowStyle.swift";
sourceTree = "<group>";
};
B9D610B162B1B80416AC881C /* build:Sources/DesignSystem/StowStyle.swift */ = {
isa = PBXBuildFile;
fileRef = 72483B00A6425B600C442BC9 /* StowStyle.swift */;
};
897F8C7966F8C33130E00F5E /* file:Sources/Features/Collection/CollectionView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "CollectionView.swift";
sourceTree = "<group>";
};
6934A69E9E18E3BB525BFF91 /* build:Sources/Features/Collection/CollectionView.swift */ = {
isa = PBXBuildFile;
fileRef = 897F8C7966F8C33130E00F5E /* CollectionView.swift */;
};
7D4460FF2CB0F8CA12712B20 /* file:Sources/Features/Home/HomeView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "HomeView.swift";
sourceTree = "<group>";
};
B58F4DCDC7AAC8EE3F207F8E /* build:Sources/Features/Home/HomeView.swift */ = {
isa = PBXBuildFile;
fileRef = 7D4460FF2CB0F8CA12712B20 /* HomeView.swift */;
};
2493A1FE108FCBA41C360F20 /* file:Sources/Features/Import/ImportSheet.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ImportSheet.swift";
sourceTree = "<group>";
};
775BF6F01D11C687449C2C2C /* build:Sources/Features/Import/ImportSheet.swift */ = {
isa = PBXBuildFile;
fileRef = 2493A1FE108FCBA41C360F20 /* ImportSheet.swift */;
};
B40FD850E9C0416D9B12796E /* file:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ItemDetailView.swift";
sourceTree = "<group>";
};
85BF25C21CD281A060378D37 /* build:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
isa = PBXBuildFile;
fileRef = B40FD850E9C0416D9B12796E /* ItemDetailView.swift */;
};
8EF91CE90E92FE3A7C5B1977 /* file:Sources/Features/Profile/ProfileView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ProfileView.swift";
sourceTree = "<group>";
};
60C5F5DE82A76A1C5BFFAB38 /* build:Sources/Features/Profile/ProfileView.swift */ = {
isa = PBXBuildFile;
fileRef = 8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */;
};
56E6F9A96C68F16C2A22C62E /* file:Sources/Features/Search/SearchView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "SearchView.swift";
sourceTree = "<group>";
};
785BC5ABE744416B4D6C1AEB /* build:Sources/Features/Search/SearchView.swift */ = {
isa = PBXBuildFile;
fileRef = 56E6F9A96C68F16C2A22C62E /* SearchView.swift */;
};
577D65EF2EC9794678A7A2A6 /* file:Sources/Models/SavedItem.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "SavedItem.swift";
sourceTree = "<group>";
};
D08C04907404CDAB1F64DFDA /* build:Sources/Models/SavedItem.swift */ = {
isa = PBXBuildFile;
fileRef = 577D65EF2EC9794678A7A2A6 /* SavedItem.swift */;
};
8A07040F49C28CF4498F3D61 /* file:Sources/Services/LocalImportService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "LocalImportService.swift";
sourceTree = "<group>";
};
F621CDD61DDDCE0C3C374485 /* build:Sources/Services/LocalImportService.swift */ = {
isa = PBXBuildFile;
fileRef = 8A07040F49C28CF4498F3D61 /* LocalImportService.swift */;
};
38A007151ABE87CC01A5B6E9 /* product */ = {
isa = PBXFileReference;
explicitFileType = wrapper.application;
includeInIndex = 0;
path = Stow.app;
sourceTree = BUILT_PRODUCTS_DIR;
};
91468586F9881985D065A03B /* group:Sources/App */ = {
isa = PBXGroup;
children = (
3362B8F6FEB9187AF06C2973 /* RootView.swift */,
09FA9998D53C167BA164487C /* StowApp.swift */,
);
path = "App";
sourceTree = "<group>";
};
D40CDA4BC33432C558328A29 /* group:Sources/Models */ = {
isa = PBXGroup;
children = (
577D65EF2EC9794678A7A2A6 /* SavedItem.swift */,
);
path = "Models";
sourceTree = "<group>";
};
EF972FBDF6EB970E6AA4CC4F /* group:Sources/Services */ = {
isa = PBXGroup;
children = (
266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */,
8A07040F49C28CF4498F3D61 /* LocalImportService.swift */,
);
path = "Services";
sourceTree = "<group>";
};
5327D0384DBAC03BEDE08B0C /* group:Sources/DesignSystem */ = {
isa = PBXGroup;
children = (
72483B00A6425B600C442BC9 /* StowStyle.swift */,
);
path = "DesignSystem";
sourceTree = "<group>";
};
3E3E6E83A02BCB9449B155AE /* group:Sources/Features/Home */ = {
isa = PBXGroup;
children = (
7D4460FF2CB0F8CA12712B20 /* HomeView.swift */,
);
path = "Home";
sourceTree = "<group>";
};
D113AB81B7A7C49B08200004 /* group:Sources/Features/Collection */ = {
isa = PBXGroup;
children = (
897F8C7966F8C33130E00F5E /* CollectionView.swift */,
);
path = "Collection";
sourceTree = "<group>";
};
24C271AB4286764A636B744D /* group:Sources/Features/Search */ = {
isa = PBXGroup;
children = (
56E6F9A96C68F16C2A22C62E /* SearchView.swift */,
);
path = "Search";
sourceTree = "<group>";
};
664EB509DC23723B40FA9BF4 /* group:Sources/Features/ItemDetail */ = {
isa = PBXGroup;
children = (
B40FD850E9C0416D9B12796E /* ItemDetailView.swift */,
);
path = "ItemDetail";
sourceTree = "<group>";
};
6EB878375904859CAE2750D4 /* group:Sources/Features/Import */ = {
isa = PBXGroup;
children = (
EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */,
2493A1FE108FCBA41C360F20 /* ImportSheet.swift */,
);
path = "Import";
sourceTree = "<group>";
};
0AEA2CEBCB47EB60E5E6948E /* group:Sources/Features/Profile */ = {
isa = PBXGroup;
children = (
8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */,
);
path = "Profile";
sourceTree = "<group>";
};
9D756A79786A998CCABA280E /* group:Sources/Features */ = {
isa = PBXGroup;
children = (
3E3E6E83A02BCB9449B155AE /* Home */,
D113AB81B7A7C49B08200004 /* Collection */,
24C271AB4286764A636B744D /* Search */,
664EB509DC23723B40FA9BF4 /* ItemDetail */,
6EB878375904859CAE2750D4 /* Import */,
0AEA2CEBCB47EB60E5E6948E /* Profile */,
);
path = Features;
sourceTree = "<group>";
};
A9012353AFD6A66EB75D4BE2 /* group:Sources */ = {
isa = PBXGroup;
children = (
91468586F9881985D065A03B /* App */,
D40CDA4BC33432C558328A29 /* Models */,
EF972FBDF6EB970E6AA4CC4F /* Services */,
5327D0384DBAC03BEDE08B0C /* DesignSystem */,
9D756A79786A998CCABA280E /* Features */,
);
path = Sources;
sourceTree = "<group>";
};
5151B46607EBC8890D8DFCC2 /* group:Products */ = {
isa = PBXGroup;
children = (
38A007151ABE87CC01A5B6E9 /* Stow.app */,
);
name = Products;
sourceTree = "<group>";
};
931BAC40E0989DC841B8C1D2 /* group:Main */ = {
isa = PBXGroup;
children = (
A9012353AFD6A66EB75D4BE2 /* Sources */,
5151B46607EBC8890D8DFCC2 /* Products */,
);
sourceTree = "<group>";
};
28BE49E1E42D704C38EEA420 /* phase:Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A924CEC2651849DCC7E97538 /* ScreenshotScanView.swift in Sources */,
817FE3FEEED3F37B0725E260 /* ScreenshotLibraryService.swift in Sources */,
E8EB797FC7B931792536B82F /* RootView.swift in Sources */,
864B5FF1FEBFBD45DF72BC12 /* StowApp.swift in Sources */,
B9D610B162B1B80416AC881C /* StowStyle.swift in Sources */,
6934A69E9E18E3BB525BFF91 /* CollectionView.swift in Sources */,
B58F4DCDC7AAC8EE3F207F8E /* HomeView.swift in Sources */,
775BF6F01D11C687449C2C2C /* ImportSheet.swift in Sources */,
85BF25C21CD281A060378D37 /* ItemDetailView.swift in Sources */,
60C5F5DE82A76A1C5BFFAB38 /* ProfileView.swift in Sources */,
785BC5ABE744416B4D6C1AEB /* SearchView.swift in Sources */,
D08C04907404CDAB1F64DFDA /* SavedItem.swift in Sources */,
F621CDD61DDDCE0C3C374485 /* LocalImportService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CA8003021206097066E984FF /* phase:Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = ();
runOnlyForDeploymentPostprocessing = 0;
};
E26E44B743A848DED8844ED9 /* phase:Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = ();
runOnlyForDeploymentPostprocessing = 0;
};
1619A08764C450712864E767 /* target:Stow */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7192A84B797C3971CC4FB286 /* Build configuration list for PBXNativeTarget "Stow" */;
buildPhases = (
28BE49E1E42D704C38EEA420 /* Sources */,
CA8003021206097066E984FF /* Frameworks */,
E26E44B743A848DED8844ED9 /* Resources */,
);
buildRules = ();
dependencies = ();
name = Stow;
productName = Stow;
productReference = 38A007151ABE87CC01A5B6E9 /* Stow.app */;
productType = "com.apple.product-type.application";
};
98F54143AB4E86B28C3AFEE0 /* project */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1500;
};
buildConfigurationList = 58BC0B3212E180EF4DCCBAEC /* Build configuration list for PBXProject "Stow" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (en, Base);
mainGroup = 931BAC40E0989DC841B8C1D2;
productRefGroup = 5151B46607EBC8890D8DFCC2;
projectDirPath = "";
projectRoot = "";
targets = (1619A08764C450712864E767 /* Stow */);
};
A9497CE23FDDC0787512981E /* config:Project:Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
SWIFT_OPTIMIZATION_LEVEL = -Onone;
};
name = Debug;
};
1D69AE994E46541145CBB141 /* config:Target:Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "收好";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
MARKETING_VERSION = 0.1.0;
CURRENT_PROJECT_VERSION = 1;
};
name = Debug;
};
A9DD297AC466B8C891118663 /* config:Project:Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
SWIFT_OPTIMIZATION_LEVEL = -O;
};
name = Release;
};
409A63BCB0B64CF6220D29A9 /* config:Target:Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "收好";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
MARKETING_VERSION = 0.1.0;
CURRENT_PROJECT_VERSION = 1;
};
name = Release;
};
58BC0B3212E180EF4DCCBAEC /* configs:Project */ = {
isa = XCConfigurationList;
buildConfigurations = (
A9497CE23FDDC0787512981E /* Debug */,
A9DD297AC466B8C891118663 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7192A84B797C3971CC4FB286 /* configs:Target */ = {
isa = XCConfigurationList;
buildConfigurations = (
1D69AE994E46541145CBB141 /* Debug */,
409A63BCB0B64CF6220D29A9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
266BC7E402A0C08B51E4A8F9 /* file:Sources/Services/ScreenshotLibraryService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ScreenshotLibraryService.swift";
sourceTree = "<group>";
};
817FE3FEEED3F37B0725E260 /* build:Sources/Services/ScreenshotLibraryService.swift */ = {
isa = PBXBuildFile;
fileRef = 266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */;
};
EA66DC9AC41434DF211AE03E /* file:Sources/Features/Import/ScreenshotScanView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ScreenshotScanView.swift";
sourceTree = "<group>";
};
A924CEC2651849DCC7E97538 /* build:Sources/Features/Import/ScreenshotScanView.swift */ = {
isa = PBXBuildFile;
fileRef = EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */;
};
};
rootObject = 98F54143AB4E86B28C3AFEE0 /* Project object */;
}