commit fe72c1ebd253afea3dbc8bddb4ac676d248290ba Author: Blizzard Date: Fri Oct 2 21:23:31 2026 +0800 feat: add Stow prototype iOS app and Gin backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7601384 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.idea/ +DerivedData/ +*.xcuserstate +xcuserdata/ +*.ipa +*.app/ +backend/stow-api diff --git a/Stow.html b/Stow.html new file mode 100644 index 0000000..473fa38 --- /dev/null +++ b/Stow.html @@ -0,0 +1,311 @@ + + + + + + + + + 收好 Stow · 产品原型 + + + + +
+
9:41●●● ▰
+
+
+
+ + + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3a8499b --- /dev/null +++ b/backend/README.md @@ -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 `。开发环境数据库密码只用于本地 Compose;生产部署需要单独配置私有数据库、TLS、对象存储和密钥管理。 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..a3964e7 --- /dev/null +++ b/backend/cmd/api/main.go @@ -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) + } +} diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..0db5255 --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -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 +} diff --git a/backend/compose.yml b/backend/compose.yml new file mode 100644 index 0000000..9d5d049 --- /dev/null +++ b/backend/compose.yml @@ -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: diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..9cb0fdc --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..20727a8 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/response.go b/backend/internal/api/response.go new file mode 100644 index 0000000..1a4fd14 --- /dev/null +++ b/backend/internal/api/response.go @@ -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}) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go new file mode 100644 index 0000000..dbfc94e --- /dev/null +++ b/backend/internal/api/server.go @@ -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 +} diff --git a/backend/internal/api/server_test.go b/backend/internal/api/server_test.go new file mode 100644 index 0000000..849c85b --- /dev/null +++ b/backend/internal/api/server_test.go @@ -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()) + } + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..e90e81d --- /dev/null +++ b/backend/internal/store/store.go @@ -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 +} diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..d9d4852 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -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); diff --git a/backend/migrations/0002_uuid_string_ids.sql b/backend/migrations/0002_uuid_string_ids.sql new file mode 100644 index 0000000..beda986 --- /dev/null +++ b/backend/migrations/0002_uuid_string_ids.sql @@ -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}$'); diff --git a/backend/migrations/0003_snake_case_columns.sql b/backend/migrations/0003_snake_case_columns.sql new file mode 100644 index 0000000..5b9c045 --- /dev/null +++ b/backend/migrations/0003_snake_case_columns.sql @@ -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; diff --git a/ios/Stow/README.md b/ios/Stow/README.md new file mode 100644 index 0000000..f7ed16b --- /dev/null +++ b/ios/Stow/README.md @@ -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 环境中进行首次编译和模拟器验收。 diff --git a/ios/Stow/Sources/App/RootView.swift b/ios/Stow/Sources/App/RootView.swift new file mode 100644 index 0000000..5a8dc79 --- /dev/null +++ b/ios/Stow/Sources/App/RootView.swift @@ -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]) + } + } +} diff --git a/ios/Stow/Sources/App/StowApp.swift b/ios/Stow/Sources/App/StowApp.swift new file mode 100644 index 0000000..61afd2e --- /dev/null +++ b/ios/Stow/Sources/App/StowApp.swift @@ -0,0 +1,12 @@ +import SwiftData +import SwiftUI + +@main +struct StowApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + .modelContainer(for: SavedItem.self) + } +} diff --git a/ios/Stow/Sources/DesignSystem/StowStyle.swift b/ios/Stow/Sources/DesignSystem/StowStyle.swift new file mode 100644 index 0000000..527682e --- /dev/null +++ b/ios/Stow/Sources/DesignSystem/StowStyle.swift @@ -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: "文本" + } + } +} diff --git a/ios/Stow/Sources/Features/Collection/CollectionView.swift b/ios/Stow/Sources/Features/Collection/CollectionView.swift new file mode 100644 index 0000000..9a1524d --- /dev/null +++ b/ios/Stow/Sources/Features/Collection/CollectionView.swift @@ -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: "值得再看" + } + } +} diff --git a/ios/Stow/Sources/Features/Home/HomeView.swift b/ios/Stow/Sources/Features/Home/HomeView.swift new file mode 100644 index 0000000..36abc8b --- /dev/null +++ b/ios/Stow/Sources/Features/Home/HomeView.swift @@ -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) + } +} diff --git a/ios/Stow/Sources/Features/Import/ImportSheet.swift b/ios/Stow/Sources/Features/Import/ImportSheet.swift new file mode 100644 index 0000000..1bb28af --- /dev/null +++ b/ios/Stow/Sources/Features/Import/ImportSheet.swift @@ -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 } + } +} diff --git a/ios/Stow/Sources/Features/Import/ScreenshotScanView.swift b/ios/Stow/Sources/Features/Import/ScreenshotScanView.swift new file mode 100644 index 0000000..3bad016 --- /dev/null +++ b/ios/Stow/Sources/Features/Import/ScreenshotScanView.swift @@ -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 = [] + @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) } + } +} diff --git a/ios/Stow/Sources/Features/ItemDetail/ItemDetailView.swift b/ios/Stow/Sources/Features/ItemDetail/ItemDetailView.swift new file mode 100644 index 0000000..35c6534 --- /dev/null +++ b/ios/Stow/Sources/Features/ItemDetail/ItemDetailView.swift @@ -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 ?? "") } + } + } +} diff --git a/ios/Stow/Sources/Features/Profile/ProfileView.swift b/ios/Stow/Sources/Features/Profile/ProfileView.swift new file mode 100644 index 0000000..ccb3b8a --- /dev/null +++ b/ios/Stow/Sources/Features/Profile/ProfileView.swift @@ -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()).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 +} diff --git a/ios/Stow/Sources/Features/Search/SearchView.swift b/ios/Stow/Sources/Features/Search/SearchView.swift new file mode 100644 index 0000000..2cb91c3 --- /dev/null +++ b/ios/Stow/Sources/Features/Search/SearchView.swift @@ -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) + } +} diff --git a/ios/Stow/Sources/Models/SavedItem.swift b/ios/Stow/Sources/Models/SavedItem.swift new file mode 100644 index 0000000..65e380f --- /dev/null +++ b/ios/Stow/Sources/Models/SavedItem.swift @@ -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" + } + } +} diff --git a/ios/Stow/Sources/Services/LocalImportService.swift b/ios/Stow/Sources/Services/LocalImportService.swift new file mode 100644 index 0000000..ea1401c --- /dev/null +++ b/ios/Stow/Sources/Services/LocalImportService.swift @@ -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()).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()).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: "无法读取这张图片。" + } + } +} diff --git a/ios/Stow/Sources/Services/ScreenshotLibraryService.swift b/ios/Stow/Sources/Services/ScreenshotLibraryService.swift new file mode 100644 index 0000000..fe226a2 --- /dev/null +++ b/ios/Stow/Sources/Services/ScreenshotLibraryService.swift @@ -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..= 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) + } + } + } + } +} diff --git a/ios/Stow/Stow.xcodeproj/project.pbxproj b/ios/Stow/Stow.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8a301a8 --- /dev/null +++ b/ios/Stow/Stow.xcodeproj/project.pbxproj @@ -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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + D40CDA4BC33432C558328A29 /* group:Sources/Models */ = { + isa = PBXGroup; + children = ( + 577D65EF2EC9794678A7A2A6 /* SavedItem.swift */, + ); + path = "Models"; + sourceTree = ""; + }; + EF972FBDF6EB970E6AA4CC4F /* group:Sources/Services */ = { + isa = PBXGroup; + children = ( + 266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */, + 8A07040F49C28CF4498F3D61 /* LocalImportService.swift */, + ); + path = "Services"; + sourceTree = ""; + }; + 5327D0384DBAC03BEDE08B0C /* group:Sources/DesignSystem */ = { + isa = PBXGroup; + children = ( + 72483B00A6425B600C442BC9 /* StowStyle.swift */, + ); + path = "DesignSystem"; + sourceTree = ""; + }; + 3E3E6E83A02BCB9449B155AE /* group:Sources/Features/Home */ = { + isa = PBXGroup; + children = ( + 7D4460FF2CB0F8CA12712B20 /* HomeView.swift */, + ); + path = "Home"; + sourceTree = ""; + }; + D113AB81B7A7C49B08200004 /* group:Sources/Features/Collection */ = { + isa = PBXGroup; + children = ( + 897F8C7966F8C33130E00F5E /* CollectionView.swift */, + ); + path = "Collection"; + sourceTree = ""; + }; + 24C271AB4286764A636B744D /* group:Sources/Features/Search */ = { + isa = PBXGroup; + children = ( + 56E6F9A96C68F16C2A22C62E /* SearchView.swift */, + ); + path = "Search"; + sourceTree = ""; + }; + 664EB509DC23723B40FA9BF4 /* group:Sources/Features/ItemDetail */ = { + isa = PBXGroup; + children = ( + B40FD850E9C0416D9B12796E /* ItemDetailView.swift */, + ); + path = "ItemDetail"; + sourceTree = ""; + }; + 6EB878375904859CAE2750D4 /* group:Sources/Features/Import */ = { + isa = PBXGroup; + children = ( + EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */, + 2493A1FE108FCBA41C360F20 /* ImportSheet.swift */, + ); + path = "Import"; + sourceTree = ""; + }; + 0AEA2CEBCB47EB60E5E6948E /* group:Sources/Features/Profile */ = { + isa = PBXGroup; + children = ( + 8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */, + ); + path = "Profile"; + sourceTree = ""; + }; + 9D756A79786A998CCABA280E /* group:Sources/Features */ = { + isa = PBXGroup; + children = ( + 3E3E6E83A02BCB9449B155AE /* Home */, + D113AB81B7A7C49B08200004 /* Collection */, + 24C271AB4286764A636B744D /* Search */, + 664EB509DC23723B40FA9BF4 /* ItemDetail */, + 6EB878375904859CAE2750D4 /* Import */, + 0AEA2CEBCB47EB60E5E6948E /* Profile */, + ); + path = Features; + sourceTree = ""; + }; + A9012353AFD6A66EB75D4BE2 /* group:Sources */ = { + isa = PBXGroup; + children = ( + 91468586F9881985D065A03B /* App */, + D40CDA4BC33432C558328A29 /* Models */, + EF972FBDF6EB970E6AA4CC4F /* Services */, + 5327D0384DBAC03BEDE08B0C /* DesignSystem */, + 9D756A79786A998CCABA280E /* Features */, + ); + path = Sources; + sourceTree = ""; + }; + 5151B46607EBC8890D8DFCC2 /* group:Products */ = { + isa = PBXGroup; + children = ( + 38A007151ABE87CC01A5B6E9 /* Stow.app */, + ); + name = Products; + sourceTree = ""; + }; + 931BAC40E0989DC841B8C1D2 /* group:Main */ = { + isa = PBXGroup; + children = ( + A9012353AFD6A66EB75D4BE2 /* Sources */, + 5151B46607EBC8890D8DFCC2 /* Products */, + ); + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + A924CEC2651849DCC7E97538 /* build:Sources/Features/Import/ScreenshotScanView.swift */ = { + isa = PBXBuildFile; + fileRef = EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */; + }; + }; + rootObject = 98F54143AB4E86B28C3AFEE0 /* Project object */; +}