feat: add Stow prototype iOS app and Gin backend
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
.idea/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
*.ipa
|
||||
*.app/
|
||||
backend/stow-api
|
||||
@@ -0,0 +1,25 @@
|
||||
# Stow Go API(开发中)
|
||||
|
||||
当前实现匿名会话、Item 创建/列表/详情/编辑/软删除,以及关键词搜索。使用 PostgreSQL 和 `pg_trgm` 建表;向量搜索需要服务器安装 `pgvector` 后再增加相应迁移。AI Worker、图片上传和云同步仍在开发。
|
||||
|
||||
数据库业务表统一使用 `stow_` 前缀,字段使用 `snake_case` 命名;业务表的主键 `id` 使用 `VARCHAR(36)` 存储 UUID 字符串,并由数据库检查格式。Go 结构体类型名不加 `Stow` 前缀,导出字段使用 Go 惯用的大驼峰写法。HTTP JSON 字段保持现有 `snake_case` 接口约定。
|
||||
|
||||
HTTP 层使用 Gin。所有响应统一为 `{"code":"ok","message":"success","data":...}`;失败时 `code` 为错误标识、`message` 为可读提示、`data` 为 `null`,同时返回对应的 HTTP 状态码。删除成功返回 HTTP 200 和同样的响应结构。
|
||||
|
||||
从 `backend/` 目录启动本地依赖与 API:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
export DATABASE_URL='postgres://stow:stow_dev@localhost:5432/stow?sslmode=disable'
|
||||
go run ./cmd/migrate
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```sh
|
||||
curl http://localhost:8080/v1/health
|
||||
curl -X POST http://localhost:8080/v1/session/anonymous
|
||||
```
|
||||
|
||||
所有 `/v1/items` 与 `/v1/search` 请求需要 `Authorization: Bearer <access_token>`。开发环境数据库密码只用于本地 Compose;生产部署需要单独配置私有数据库、TLS、对象存储和密钥管理。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,44 @@
|
||||
module stow/backend
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
+100
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE TABLE stow_users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"isAnonymous" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE stow_sessions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
|
||||
"tokenHash" BYTEA NOT NULL UNIQUE,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"expiresAt" TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX stow_sessions_userId_idx ON stow_sessions ("userId");
|
||||
|
||||
CREATE TABLE stow_items (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"userId" VARCHAR(36) NOT NULL REFERENCES stow_users(id) ON DELETE CASCADE,
|
||||
"sourceType" VARCHAR(32) NOT NULL,
|
||||
"sourceUrl" TEXT,
|
||||
"primaryType" VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ready',
|
||||
"ocrText" TEXT,
|
||||
"searchText" TEXT NOT NULL DEFAULT '',
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"capturedAt" TIMESTAMPTZ,
|
||||
"readyAt" TIMESTAMPTZ,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"deletedAt" TIMESTAMPTZ,
|
||||
version BIGINT NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX stow_items_userId_createdAt_idx ON stow_items ("userId", "createdAt" DESC) WHERE "deletedAt" IS NULL;
|
||||
CREATE INDEX stow_items_userId_updatedAt_idx ON stow_items ("userId", "updatedAt", id);
|
||||
CREATE INDEX stow_items_searchText_trgm_idx ON stow_items USING gin ("searchText" gin_trgm_ops) WHERE "deletedAt" IS NULL;
|
||||
|
||||
CREATE TABLE stow_assets (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
|
||||
"objectKey" TEXT,
|
||||
"thumbnailKey" TEXT,
|
||||
"mimeType" VARCHAR(128),
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
"sizeBytes" BIGINT,
|
||||
sha256 VARCHAR(64),
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX stow_assets_itemId_sha256_idx ON stow_assets ("itemId", sha256) WHERE sha256 IS NOT NULL;
|
||||
|
||||
CREATE TABLE stow_item_analyses (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
"itemId" VARCHAR(36) NOT NULL REFERENCES stow_items(id) ON DELETE CASCADE,
|
||||
"pipelineVersion" VARCHAR(64) NOT NULL,
|
||||
"promptVersion" VARCHAR(64),
|
||||
"modelProvider" VARCHAR(64),
|
||||
"modelName" VARCHAR(128),
|
||||
"resultJson" JSONB NOT NULL,
|
||||
confidence REAL,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX stow_item_analyses_itemId_createdAt_idx ON stow_item_analyses ("itemId", "createdAt" DESC);
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE stow_users ADD CONSTRAINT stow_users_id_uuid_check
|
||||
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
|
||||
ALTER TABLE stow_sessions ADD CONSTRAINT stow_sessions_id_uuid_check
|
||||
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
|
||||
ALTER TABLE stow_items ADD CONSTRAINT stow_items_id_uuid_check
|
||||
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
|
||||
ALTER TABLE stow_assets ADD CONSTRAINT stow_assets_id_uuid_check
|
||||
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
|
||||
ALTER TABLE stow_item_analyses ADD CONSTRAINT stow_item_analyses_id_uuid_check
|
||||
CHECK (id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$');
|
||||
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE stow_users RENAME COLUMN "isAnonymous" TO is_anonymous;
|
||||
ALTER TABLE stow_users RENAME COLUMN "createdAt" TO created_at;
|
||||
ALTER TABLE stow_users RENAME COLUMN "updatedAt" TO updated_at;
|
||||
|
||||
ALTER TABLE stow_sessions RENAME COLUMN "userId" TO user_id;
|
||||
ALTER TABLE stow_sessions RENAME COLUMN "tokenHash" TO token_hash;
|
||||
ALTER TABLE stow_sessions RENAME COLUMN "createdAt" TO created_at;
|
||||
ALTER TABLE stow_sessions RENAME COLUMN "expiresAt" TO expires_at;
|
||||
|
||||
ALTER TABLE stow_items RENAME COLUMN "userId" TO user_id;
|
||||
ALTER TABLE stow_items RENAME COLUMN "sourceType" TO source_type;
|
||||
ALTER TABLE stow_items RENAME COLUMN "sourceUrl" TO source_url;
|
||||
ALTER TABLE stow_items RENAME COLUMN "primaryType" TO primary_type;
|
||||
ALTER TABLE stow_items RENAME COLUMN "ocrText" TO ocr_text;
|
||||
ALTER TABLE stow_items RENAME COLUMN "searchText" TO search_text;
|
||||
ALTER TABLE stow_items RENAME COLUMN "capturedAt" TO captured_at;
|
||||
ALTER TABLE stow_items RENAME COLUMN "readyAt" TO ready_at;
|
||||
ALTER TABLE stow_items RENAME COLUMN "createdAt" TO created_at;
|
||||
ALTER TABLE stow_items RENAME COLUMN "updatedAt" TO updated_at;
|
||||
ALTER TABLE stow_items RENAME COLUMN "deletedAt" TO deleted_at;
|
||||
|
||||
ALTER TABLE stow_assets RENAME COLUMN "itemId" TO item_id;
|
||||
ALTER TABLE stow_assets RENAME COLUMN "objectKey" TO object_key;
|
||||
ALTER TABLE stow_assets RENAME COLUMN "thumbnailKey" TO thumbnail_key;
|
||||
ALTER TABLE stow_assets RENAME COLUMN "mimeType" TO mime_type;
|
||||
ALTER TABLE stow_assets RENAME COLUMN "sizeBytes" TO size_bytes;
|
||||
ALTER TABLE stow_assets RENAME COLUMN "createdAt" TO created_at;
|
||||
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "itemId" TO item_id;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "pipelineVersion" TO pipeline_version;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "promptVersion" TO prompt_version;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "modelProvider" TO model_provider;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "modelName" TO model_name;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "resultJson" TO result_json;
|
||||
ALTER TABLE stow_item_analyses RENAME COLUMN "createdAt" TO created_at;
|
||||
|
||||
ALTER TABLE stow_schema_migrations RENAME COLUMN "appliedAt" TO applied_at;
|
||||
@@ -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 环境中进行首次编译和模拟器验收。
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct StowApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.modelContainer(for: SavedItem.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StowStyle {
|
||||
static let forest = Color(red: 0.16, green: 0.39, blue: 0.27)
|
||||
static let ink = Color(red: 0.13, green: 0.23, blue: 0.17)
|
||||
static let muted = Color(red: 0.47, green: 0.57, blue: 0.49)
|
||||
static let canvas = Color(red: 0.975, green: 0.973, blue: 0.955)
|
||||
static let paleGreen = Color(red: 0.88, green: 0.94, blue: 0.87)
|
||||
}
|
||||
|
||||
struct StowSectionHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.system(size: 19, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemArtwork: View {
|
||||
let item: SavedItem
|
||||
var size: CGFloat = 70
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url = LocalImportService.imageURL(for: item),
|
||||
let image = UIImage(contentsOfFile: url.path) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [StowStyle.paleGreen, Color(red: 0.66, green: 0.78, blue: 0.68)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
Image(systemName: ItemCategory(rawValue: item.primaryType)?.symbol ?? "square.stack")
|
||||
.font(.system(size: size * 0.33, weight: .light))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 13))
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemRow: View {
|
||||
let item: SavedItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ItemArtwork(item: item)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
.lineLimit(1)
|
||||
Text(item.summary.isEmpty ? ItemCategory(rawValue: item.primaryType)?.title ?? "收藏" : item.summary)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
.lineLimit(1)
|
||||
Text("\(sourceName) · \(item.createdAt.formatted(date: .abbreviated, time: .omitted))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(StowStyle.muted.opacity(0.8))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(StowStyle.muted.opacity(0.6))
|
||||
}
|
||||
.padding(9)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 17))
|
||||
}
|
||||
|
||||
private var sourceName: String {
|
||||
switch item.sourceType {
|
||||
case "manual_photo", "screenshot": "图片"
|
||||
case "manual_url", "share_url": "链接"
|
||||
default: "文本"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CollectionView: View {
|
||||
let items: [SavedItem]
|
||||
var initialGroup: String? = nil
|
||||
let openImport: () -> Void
|
||||
|
||||
@State private var category: ItemCategory = .all
|
||||
|
||||
private var filteredItems: [SavedItem] {
|
||||
items.filter { item in
|
||||
(category == .all || item.primaryType == category.rawValue) &&
|
||||
(initialGroup == nil || item.tags.contains(initialGroup!))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("YOUR COLLECTION")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Text(initialGroup == nil ? "我的收藏" : groupTitle)
|
||||
.font(.system(size: 29, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: openImport) {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: 38, height: 38)
|
||||
.background(.white, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("添加收藏")
|
||||
}
|
||||
|
||||
Text(initialGroup == nil ? "一共收好了 \(items.count) 条内容。" : "收好自动放在一起的内容。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 7) {
|
||||
ForEach(ItemCategory.allCases) { option in
|
||||
Button { category = option } label: {
|
||||
Text(option.title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(category == option ? Color.white : StowStyle.muted)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(category == option ? StowStyle.forest : Color.white, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(filteredItems.count) 条收藏")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
|
||||
if filteredItems.isEmpty {
|
||||
ContentUnavailableView("这里还没有内容", systemImage: "square.stack", description: Text("试试其他分类,或添加一条新收藏。"))
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(filteredItems) { item in
|
||||
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.toolbar(initialGroup == nil ? .hidden : .visible, for: .navigationBar)
|
||||
}
|
||||
|
||||
private var groupTitle: String {
|
||||
switch initialGroup {
|
||||
case "想吃": "想吃的"
|
||||
case "想去": "想去的"
|
||||
case "想买": "想买的"
|
||||
default: "值得再看"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
let items: [SavedItem]
|
||||
let openImport: () -> Void
|
||||
let openSearch: () -> Void
|
||||
|
||||
private let groups: [(String, String, String, Color)] = [
|
||||
("想吃的", "想吃", "fork.knife", Color(red: 0.96, green: 0.89, blue: 0.83)),
|
||||
("想去的", "想去", "mappin", Color(red: 0.87, green: 0.93, blue: 0.87)),
|
||||
("想买的", "想买", "bag", Color(red: 0.92, green: 0.90, blue: 0.95)),
|
||||
("值得再看", "值得再看", "books.vertical", Color(red: 0.96, green: 0.93, blue: 0.83))
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
HStack {
|
||||
Label("收好", systemImage: "square.stack.3d.up.fill")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
Spacer()
|
||||
Button(action: openImport) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.background(.white, in: Circle())
|
||||
}
|
||||
.accessibilityLabel("添加收藏")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Date.now.formatted(.dateTime.month(.wide).day().weekday(.wide)))
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Text("看过的好东西,\n都在这里。")
|
||||
.font(.system(size: 31, weight: .bold))
|
||||
.tracking(-1)
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
Text("顺手收好,想找时就找得到。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
|
||||
Button(action: openSearch) {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
Text("找一下之前收藏过的东西…")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Spacer()
|
||||
}
|
||||
.padding(17)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 15))
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ScreenshotScanView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "photo.stack")
|
||||
.font(.system(size: 27))
|
||||
.frame(width: 48, height: 48)
|
||||
.background(.white.opacity(0.17), in: RoundedRectangle(cornerRadius: 12))
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("有新截图想整理?")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
Text("查看最近截图,只导入你选中的内容")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.right")
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(17)
|
||||
.background(StowStyle.forest, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
StowSectionHeader(title: "为你整理")
|
||||
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 10) {
|
||||
ForEach(groups.indices, id: \.self) { index in
|
||||
let group = groups[index]
|
||||
NavigationLink {
|
||||
CollectionView(items: items, initialGroup: group.1, openImport: openImport)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 11) {
|
||||
HStack {
|
||||
Image(systemName: group.2)
|
||||
.font(.system(size: 22))
|
||||
Spacer()
|
||||
Text("\(items.filter { $0.tags.contains(group.1) }.count)")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
}
|
||||
Text(group.0)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
.padding(17)
|
||||
.frame(maxWidth: .infinity, minHeight: 112, alignment: .leading)
|
||||
.background(group.3, in: RoundedRectangle(cornerRadius: 17))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
StowSectionHeader(title: "刚刚收好")
|
||||
if items.isEmpty {
|
||||
Button(action: openImport) {
|
||||
ContentUnavailableView("还没有收藏", systemImage: "square.and.arrow.down", description: Text("先添加一张图片、一条链接或一段文字。"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
ForEach(items.prefix(5)) { item in
|
||||
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSheet: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var mode: ImportMode = .choices
|
||||
@State private var text = ""
|
||||
@State private var url = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private enum ImportMode { case choices, text, url }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch mode {
|
||||
case .choices: choices
|
||||
case .text: textForm
|
||||
case .url: urlForm
|
||||
}
|
||||
}
|
||||
.navigationTitle("把好东西收进来")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { Button("关闭") { dismiss() } }
|
||||
}
|
||||
}
|
||||
.tint(StowStyle.forest)
|
||||
.onChange(of: selectedPhoto) { _, photo in
|
||||
guard let photo else { return }
|
||||
Task {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
guard let data = try await photo.loadTransferable(type: Data.self) else { throw ImportError.invalidImage }
|
||||
try await LocalImportService.saveImage(data, in: context)
|
||||
dismiss()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
.alert("无法收好", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("知道了", role: .cancel) { errorMessage = nil }
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
.overlay { if isWorking { ProgressView("正在识别并保存…").padding(24).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } }
|
||||
}
|
||||
|
||||
private var choices: some View {
|
||||
VStack(alignment: .leading, spacing: 13) {
|
||||
Text("选一种最顺手的方式,剩下的交给收好。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
.padding(.bottom, 10)
|
||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
||||
choice("选择截图或照片", "从相册中手动选取", "photo.on.rectangle")
|
||||
}
|
||||
NavigationLink {
|
||||
ScreenshotScanView()
|
||||
} label: {
|
||||
choice("整理最近截图", "选取最近的截图批量收好", "rectangle.stack")
|
||||
}
|
||||
Button { mode = .url } label: { choice("保存链接", "文章、商品、攻略都可以", "link") }
|
||||
Button { mode = .text } label: { choice("保存文本", "把一段文字收起来", "text.alignleft") }
|
||||
Spacer()
|
||||
Text("目前只处理你主动选择的内容,数据保存在本机。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
.padding(22)
|
||||
.background(StowStyle.canvas)
|
||||
}
|
||||
|
||||
private var textForm: some View {
|
||||
Form {
|
||||
Section("内容") {
|
||||
TextField("粘贴想记住的内容…", text: $text, axis: .vertical)
|
||||
.lineLimit(5...10)
|
||||
}
|
||||
Section {
|
||||
Button("收好这段文字") { save { try LocalImportService.saveText(text, in: context) } }
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var urlForm: some View {
|
||||
Form {
|
||||
Section("链接地址") { TextField("https://example.com", text: $url).textInputAutocapitalization(.never).keyboardType(.URL).autocorrectionDisabled() }
|
||||
Section {
|
||||
Button("收好这个链接") { save { try LocalImportService.saveURL(url, in: context) } }
|
||||
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func choice(_ title: String, _ subtitle: String, _ symbol: String) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 20))
|
||||
.frame(width: 42, height: 42)
|
||||
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 12))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.system(size: 15, weight: .semibold))
|
||||
Text(subtitle).font(.system(size: 12)).foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 11))
|
||||
}
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
.padding(14)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 15))
|
||||
}
|
||||
|
||||
private func save(_ operation: () throws -> Void) {
|
||||
do { try operation(); dismiss() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import Photos
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ScreenshotScanView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var candidates: [ScreenshotCandidate] = []
|
||||
@State private var selectedIDs: Set<String> = []
|
||||
@State private var phase: Phase = .introduction
|
||||
@State private var importedCount = 0
|
||||
@State private var failedCount = 0
|
||||
|
||||
private enum Phase { case introduction, loading, selection, importing, complete, denied }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch phase {
|
||||
case .introduction: introduction
|
||||
case .loading: loading
|
||||
case .selection: selection
|
||||
case .importing: importing
|
||||
case .complete: completion
|
||||
case .denied: denied
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.navigationTitle("整理截图")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var introduction: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 39))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
.padding(20)
|
||||
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 20))
|
||||
Text("帮你整理最近的截图")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
Text("收好会找出照片库里的截图,只展示最近 30 张,由你确认哪些需要导入。你的内容不会公开。")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
.lineSpacing(5)
|
||||
Button("查看最近截图") { Task { await loadScreenshots() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(StowStyle.forest)
|
||||
Text("也可以返回,使用系统照片选择器手动挑选。")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
.padding(.top, 34)
|
||||
}
|
||||
|
||||
private var loading: some View {
|
||||
ProgressView("正在查找截图…")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
|
||||
private var selection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("发现 \(candidates.count) 张最近的截图")
|
||||
.font(.system(size: 23, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
Text("已选择 \(selectedIDs.count) 张 · 只处理你确认的内容")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
|
||||
if candidates.isEmpty {
|
||||
ContentUnavailableView("没有发现截图", systemImage: "photo", description: Text("你仍然可以使用手动选择照片。"))
|
||||
} else {
|
||||
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 12) {
|
||||
ForEach(candidates) { candidate in
|
||||
Button { toggle(candidate.id) } label: {
|
||||
ScreenshotTile(candidate: candidate, selected: selectedIDs.contains(candidate.id))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Button("收好这 \(selectedIDs.count) 张") { Task { await importSelected() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(StowStyle.forest)
|
||||
.disabled(selectedIDs.isEmpty)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var importing: some View {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
Text("正在识别并保存截图…")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text("已收好 \(importedCount) 张。OCR 失败时,图片仍会保存。")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
}
|
||||
|
||||
private var completion: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
Text("\(importedCount) 张截图已经收好")
|
||||
.font(.system(size: 25, weight: .bold))
|
||||
if failedCount > 0 {
|
||||
Text("另有 \(failedCount) 张未能读取,可以稍后重试。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
Button("完成") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(StowStyle.forest)
|
||||
}
|
||||
.padding(.top, 40)
|
||||
}
|
||||
|
||||
private var denied: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("还不能查看截图")
|
||||
.font(.system(size: 25, weight: .bold))
|
||||
Text("请到系统设置中允许收好访问照片,或返回使用手动选择照片。")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
if let settings = URL(string: UIApplication.openSettingsURLString) {
|
||||
Link("打开设置", destination: settings)
|
||||
}
|
||||
}
|
||||
.padding(.top, 40)
|
||||
}
|
||||
|
||||
private func toggle(_ id: String) {
|
||||
if selectedIDs.contains(id) { selectedIDs.remove(id) }
|
||||
else { selectedIDs.insert(id) }
|
||||
}
|
||||
|
||||
private func loadScreenshots() async {
|
||||
phase = .loading
|
||||
let access = await ScreenshotLibraryService.requestAccess()
|
||||
guard access == .authorized || access == .limited else { phase = .denied; return }
|
||||
do {
|
||||
let recent = ScreenshotLibraryService.recentScreenshots()
|
||||
candidates = try recent.filter { try !LocalImportService.hasImported($0.id, in: context) }
|
||||
selectedIDs = Set(candidates.prefix(5).map(\.id))
|
||||
phase = .selection
|
||||
} catch {
|
||||
phase = .denied
|
||||
}
|
||||
}
|
||||
|
||||
private func importSelected() async {
|
||||
phase = .importing
|
||||
importedCount = 0
|
||||
failedCount = 0
|
||||
for candidate in candidates where selectedIDs.contains(candidate.id) {
|
||||
do {
|
||||
let data = try await ScreenshotLibraryService.imageData(for: candidate.asset)
|
||||
let saved = try await LocalImportService.saveImage(
|
||||
data,
|
||||
sourceType: "screenshot",
|
||||
photoAssetIdentifier: candidate.id,
|
||||
capturedAt: candidate.capturedAt,
|
||||
in: context
|
||||
)
|
||||
if saved { importedCount += 1 }
|
||||
} catch {
|
||||
failedCount += 1
|
||||
}
|
||||
}
|
||||
phase = .complete
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScreenshotTile: View {
|
||||
let candidate: ScreenshotCandidate
|
||||
let selected: Bool
|
||||
@State private var thumbnail: UIImage?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Group {
|
||||
if let thumbnail {
|
||||
Image(uiImage: thumbnail).resizable().scaledToFill()
|
||||
} else {
|
||||
Rectangle().fill(StowStyle.paleGreen)
|
||||
.overlay { Image(systemName: "photo").foregroundStyle(StowStyle.muted) }
|
||||
}
|
||||
}
|
||||
.frame(height: 170)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipped()
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(selected ? StowStyle.forest : .white)
|
||||
.padding(9)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay { RoundedRectangle(cornerRadius: 14).stroke(selected ? StowStyle.forest : .clear, lineWidth: 2) }
|
||||
.task(id: candidate.id) { thumbnail = await ScreenshotLibraryService.thumbnail(for: candidate.asset) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ItemDetailView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let item: SavedItem
|
||||
|
||||
@State private var showingEdit = false
|
||||
@State private var showingDelete = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 23) {
|
||||
ItemArtwork(item: item, size: 270)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text((ItemCategory(rawValue: item.primaryType)?.title ?? "其他") + " · " + item.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Text(item.title)
|
||||
.font(.system(size: 27, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("收好帮你整理", systemImage: "sparkle")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
Text(item.summary.isEmpty ? "这条内容已收好。" : item.summary)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
.lineSpacing(5)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
|
||||
|
||||
if !item.tags.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
StowSectionHeader(title: "标签")
|
||||
FlowTags(tags: item.tags)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
StowSectionHeader(title: "原始内容")
|
||||
Text(item.sourceType == "manual_photo" ? "手动选择的图片" : item.sourceType == "manual_url" ? "手动保存的链接" : "手动保存的文本")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
if let sourceURL = item.sourceURL, let url = URL(string: sourceURL) {
|
||||
Link(destination: url) {
|
||||
Label("打开原链接", systemImage: "arrow.up.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button { showingEdit = true } label: {
|
||||
Label("编辑整理结果", systemImage: "pencil")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button(role: .destructive) { showingDelete = true } label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("删除收藏")
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.navigationTitle("收藏详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showingEdit) { EditItemSheet(item: item) }
|
||||
.confirmationDialog("删除这条收藏?", isPresented: $showingDelete) {
|
||||
Button("删除", role: .destructive) {
|
||||
do { try LocalImportService.delete(item, in: context); dismiss() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
.alert("操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("知道了", role: .cancel) { errorMessage = nil }
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
private struct FlowTags: View {
|
||||
let tags: [String]
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack { tagViews }
|
||||
VStack(alignment: .leading) { tagViews }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var tagViews: some View {
|
||||
ForEach(tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 8)
|
||||
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditItemSheet: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let item: SavedItem
|
||||
|
||||
@State private var title: String
|
||||
@State private var summary: String
|
||||
@State private var tags: String
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(item: SavedItem) {
|
||||
self.item = item
|
||||
_title = State(initialValue: item.title)
|
||||
_summary = State(initialValue: item.summary)
|
||||
_tags = State(initialValue: item.tags.joined(separator: ","))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
TextField("标题", text: $title)
|
||||
TextField("摘要", text: $summary, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
TextField("标签,用逗号分隔", text: $tags)
|
||||
}
|
||||
.navigationTitle("编辑收藏")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") {
|
||||
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
errorMessage = "标题不能为空。"; return
|
||||
}
|
||||
item.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
item.summary = summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
item.tags = tags.split(whereSeparator: { $0 == "," || $0 == "," }).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
||||
do { try context.save(); dismiss() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("无法保存", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("知道了", role: .cancel) { errorMessage = nil }
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
let itemCount: Int
|
||||
@State private var debugMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 23) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("YOUR SPACE")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Text("我的")
|
||||
.font(.system(size: 29, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
}
|
||||
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "square.stack.3d.up.fill")
|
||||
.font(.system(size: 25))
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.white.opacity(0.6), in: RoundedRectangle(cornerRadius: 15))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("好东西,都收好了")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text("本地体验 · 无需登录")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(itemCount)").font(.system(size: 26, weight: .bold)).foregroundStyle(StowStyle.forest)
|
||||
Text("已收好").font(.system(size: 12)).foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
Spacer()
|
||||
Text("当前内容仅保存在这台设备")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
.padding(18)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
StowSectionHeader(title: "关于数据")
|
||||
Text("你主动保存的图片会存入 App 的本地文件夹,收藏信息保存在本地数据库。当前版本尚未接入账号、同步和云端 AI。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
.lineSpacing(5)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
StowSectionHeader(title: "开发调试")
|
||||
Button("加入演示收藏") { addDebugItems() }
|
||||
.buttonStyle(.bordered)
|
||||
Text("用于检查首页、分类、搜索和详情。正式构建不会显示此入口。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.alert("调试数据", isPresented: Binding(get: { debugMessage != nil }, set: { if !$0 { debugMessage = nil } })) {
|
||||
Button("知道了", role: .cancel) { debugMessage = nil }
|
||||
} message: { Text(debugMessage ?? "") }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func addDebugItems() {
|
||||
do {
|
||||
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.sourceType == "debug" }) {
|
||||
debugMessage = "演示收藏已经加入。"
|
||||
return
|
||||
}
|
||||
context.insert(SavedItem(title: "上海静安的咖啡店", summary: "周末想去的咖啡店,推荐 Dirty。", primaryType: "place", sourceType: "debug", tags: ["上海", "静安", "咖啡", "想去", "想吃"]))
|
||||
context.insert(SavedItem(title: "黑色机械键盘", summary: "收藏的一款黑色机械键盘。", primaryType: "product", sourceType: "debug", tags: ["黑色", "键盘", "想买"]))
|
||||
context.insert(SavedItem(title: "杭州旅行攻略", summary: "周末两日游,包含西湖和灵隐寺。", primaryType: "content", sourceType: "debug", tags: ["杭州", "旅行", "值得再看"]))
|
||||
try context.save()
|
||||
debugMessage = "已加入 3 条演示收藏。"
|
||||
} catch {
|
||||
debugMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
let items: [SavedItem]
|
||||
@State private var query = ""
|
||||
|
||||
private let suggestions = ["黑色键盘", "上海的咖啡店", "杭州旅行攻略", "AI Agent 文章"]
|
||||
|
||||
private var results: [SavedItem] {
|
||||
let value = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !value.isEmpty else { return [] }
|
||||
let knownTerms = ["黑色", "键盘", "上海", "咖啡", "杭州", "旅行", "攻略", "ai agent", "文章", "餐厅", "展览", "商品"]
|
||||
let recognized = knownTerms.filter(value.contains)
|
||||
let terms = recognized.isEmpty
|
||||
? value.split(whereSeparator: { $0.isWhitespace || $0.isPunctuation }).map(String.init)
|
||||
: recognized
|
||||
return items.filter { item in
|
||||
if value.contains("上个月") {
|
||||
let daysAgo = Date.now.timeIntervalSince(item.createdAt) / 86400
|
||||
guard (28.0...62.0).contains(daysAgo) else { return false }
|
||||
}
|
||||
let text = ([item.title, item.summary, item.ocrText ?? ""] + item.tags)
|
||||
.joined(separator: " ").lowercased()
|
||||
return terms.allSatisfy(text.contains)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("FIND IT AGAIN")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
Text("想找什么?")
|
||||
.font(.system(size: 29, weight: .bold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
TextField("输入标题、地点或关键词", text: $query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
if !query.isEmpty {
|
||||
Button { query = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
}
|
||||
.padding(15)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 15))
|
||||
|
||||
if query.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
StowSectionHeader(title: "试着这样找")
|
||||
ForEach(suggestions, id: \.self) { suggestion in
|
||||
Button { query = suggestion } label: {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(StowStyle.forest)
|
||||
Text(suggestion)
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.foregroundStyle(StowStyle.muted)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 13))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if results.isEmpty {
|
||||
ContentUnavailableView.search(text: query)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("找到 \(results.count) 条收藏")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(StowStyle.ink)
|
||||
ForEach(results) { item in
|
||||
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.background(StowStyle.canvas)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SavedItem {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var title: String
|
||||
var summary: String
|
||||
var primaryType: String
|
||||
var sourceType: String
|
||||
var tags: [String]
|
||||
var sourceURL: String?
|
||||
var imageFilename: String?
|
||||
var photoAssetIdentifier: String?
|
||||
var imageSHA256: String?
|
||||
var ocrText: String?
|
||||
var capturedAt: Date
|
||||
var createdAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
summary: String = "",
|
||||
primaryType: String = "other",
|
||||
sourceType: String,
|
||||
tags: [String] = [],
|
||||
sourceURL: String? = nil,
|
||||
imageFilename: String? = nil,
|
||||
photoAssetIdentifier: String? = nil,
|
||||
imageSHA256: String? = nil,
|
||||
ocrText: String? = nil,
|
||||
capturedAt: Date = .now,
|
||||
createdAt: Date = .now
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.summary = summary
|
||||
self.primaryType = primaryType
|
||||
self.sourceType = sourceType
|
||||
self.tags = tags
|
||||
self.sourceURL = sourceURL
|
||||
self.imageFilename = imageFilename
|
||||
self.photoAssetIdentifier = photoAssetIdentifier
|
||||
self.imageSHA256 = imageSHA256
|
||||
self.ocrText = ocrText
|
||||
self.capturedAt = capturedAt
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
enum ItemCategory: String, CaseIterable, Identifiable {
|
||||
case all, place, product, content, event, task, document, conversation, other
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .place: "地点"
|
||||
case .product: "商品"
|
||||
case .content: "内容"
|
||||
case .event: "活动"
|
||||
case .task: "待办"
|
||||
case .document: "资料"
|
||||
case .conversation: "聊天"
|
||||
case .other: "其他"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .all: "square.grid.2x2"
|
||||
case .place: "mappin"
|
||||
case .product: "bag"
|
||||
case .content: "text.book.closed"
|
||||
case .event: "calendar"
|
||||
case .task: "checkmark.circle"
|
||||
case .document: "doc.text"
|
||||
case .conversation: "bubble.left.and.bubble.right"
|
||||
case .other: "square.stack"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import UIKit
|
||||
import Vision
|
||||
|
||||
@MainActor
|
||||
enum LocalImportService {
|
||||
static func saveText(_ text: String, in context: ModelContext) throws {
|
||||
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
let title = String(value.prefix(34))
|
||||
let item = SavedItem(
|
||||
title: title,
|
||||
summary: value,
|
||||
primaryType: category(for: value).rawValue,
|
||||
sourceType: "manual_text",
|
||||
tags: tags(for: value)
|
||||
)
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
static func saveURL(_ urlText: String, in context: ModelContext) throws {
|
||||
let value = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: value), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
|
||||
throw ImportError.invalidURL
|
||||
}
|
||||
let title = url.host?.replacingOccurrences(of: "www.", with: "") ?? "网页链接"
|
||||
let item = SavedItem(
|
||||
title: title,
|
||||
summary: value,
|
||||
primaryType: "content",
|
||||
sourceType: "manual_url",
|
||||
tags: ["链接"],
|
||||
sourceURL: value
|
||||
)
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveImage(
|
||||
_ data: Data,
|
||||
sourceType: String = "manual_photo",
|
||||
photoAssetIdentifier: String? = nil,
|
||||
capturedAt: Date = .now,
|
||||
in context: ModelContext
|
||||
) async throws -> Bool {
|
||||
if let photoAssetIdentifier, try hasImported(photoAssetIdentifier, in: context) { return false }
|
||||
guard let image = UIImage(data: data) else { throw ImportError.invalidImage }
|
||||
let id = UUID()
|
||||
let filename = "\(id.uuidString).jpg"
|
||||
let folder = try imageFolder()
|
||||
let destination = folder.appendingPathComponent(filename)
|
||||
let preparedImage = resized(image, maxDimension: 1800)
|
||||
guard let jpeg = preparedImage.jpegData(compressionQuality: 0.82) else { throw ImportError.invalidImage }
|
||||
let fingerprint = SHA256.hash(data: jpeg).map { String(format: "%02x", $0) }.joined()
|
||||
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.imageSHA256 == fingerprint }) { return false }
|
||||
try jpeg.write(to: destination, options: .atomic)
|
||||
|
||||
do {
|
||||
// The original selection remains saved even when OCR cannot read it.
|
||||
let ocr = (try? await recognizeText(in: preparedImage)) ?? ""
|
||||
let firstLine = ocr.split(separator: "\n").first.map(String.init) ?? "新图片"
|
||||
let text = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let item = SavedItem(
|
||||
id: id,
|
||||
title: String(text.prefix(42)).isEmpty ? "新图片" : String(text.prefix(42)),
|
||||
summary: String(ocr.prefix(180)),
|
||||
primaryType: category(for: ocr).rawValue,
|
||||
sourceType: sourceType,
|
||||
tags: tags(for: ocr),
|
||||
imageFilename: filename,
|
||||
photoAssetIdentifier: photoAssetIdentifier,
|
||||
imageSHA256: fingerprint,
|
||||
ocrText: ocr,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
return true
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func imageURL(for item: SavedItem) -> URL? {
|
||||
guard let filename = item.imageFilename else { return nil }
|
||||
return try? imageFolder().appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
static func delete(_ item: SavedItem, in context: ModelContext) throws {
|
||||
let fileURL = imageURL(for: item)
|
||||
context.delete(item)
|
||||
try context.save()
|
||||
if let fileURL { try? FileManager.default.removeItem(at: fileURL) }
|
||||
}
|
||||
|
||||
static func hasImported(_ identifier: String, in context: ModelContext) throws -> Bool {
|
||||
try context.fetch(FetchDescriptor<SavedItem>()).contains { $0.photoAssetIdentifier == identifier }
|
||||
}
|
||||
|
||||
nonisolated private static func imageFolder() throws -> URL {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let folder = documents.appendingPathComponent("SavedImages", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
return folder
|
||||
}
|
||||
|
||||
private static func recognizeText(in image: UIImage) async throws -> String {
|
||||
guard let cgImage = image.cgImage else { return "" }
|
||||
return try await Task.detached(priority: .userInitiated) {
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.recognitionLanguages = ["zh-Hans", "en-US"]
|
||||
request.usesLanguageCorrection = true
|
||||
try VNImageRequestHandler(cgImage: cgImage).perform([request])
|
||||
return (request.results ?? [])
|
||||
.compactMap { $0.topCandidates(1).first?.string }
|
||||
.joined(separator: "\n")
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func resized(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let longestSide = max(image.size.width, image.size.height)
|
||||
guard longestSide > maxDimension else { return image }
|
||||
let scale = maxDimension / longestSide
|
||||
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
private static func category(for text: String) -> ItemCategory {
|
||||
if ["咖啡", "餐厅", "酒店", "地址", "景点"].contains(where: text.contains) { return .place }
|
||||
if ["商品", "价格", "购买", "键盘", "耳机"].contains(where: text.contains) { return .product }
|
||||
if ["展览", "演出", "活动", "门票"].contains(where: text.contains) { return .event }
|
||||
return .content
|
||||
}
|
||||
|
||||
private static func tags(for text: String) -> [String] {
|
||||
let words = ["咖啡", "餐厅", "旅行", "上海", "杭州", "商品", "键盘", "展览", "AI"]
|
||||
return words.filter(text.contains).prefix(4).map { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
enum ImportError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidImage
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: "请输入有效的 http 或 https 链接。"
|
||||
case .invalidImage: "无法读取这张图片。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
struct ScreenshotCandidate: Identifiable {
|
||||
let id: String
|
||||
let asset: PHAsset
|
||||
let capturedAt: Date
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum ScreenshotLibraryService {
|
||||
static func requestAccess() async -> PHAuthorizationStatus {
|
||||
let current = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
guard current == .notDetermined else { return current }
|
||||
return await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func recentScreenshots(limit: Int = 30) -> [ScreenshotCandidate] {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
let assets = PHAsset.fetchAssets(with: .image, options: options)
|
||||
var screenshots: [ScreenshotCandidate] = []
|
||||
for index in 0..<assets.count {
|
||||
let asset = assets.object(at: index)
|
||||
guard asset.mediaSubtypes.contains(.photoScreenshot) else { continue }
|
||||
screenshots.append(ScreenshotCandidate(
|
||||
id: asset.localIdentifier,
|
||||
asset: asset,
|
||||
capturedAt: asset.creationDate ?? .now
|
||||
))
|
||||
if screenshots.count >= limit { break }
|
||||
}
|
||||
return screenshots
|
||||
}
|
||||
|
||||
static func thumbnail(for asset: PHAsset) async -> UIImage? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.resizeMode = .fast
|
||||
options.isNetworkAccessAllowed = false
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: 300, height: 300),
|
||||
contentMode: .aspectFill,
|
||||
options: options
|
||||
) { image, _ in
|
||||
continuation.resume(returning: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func imageData(for asset: PHAsset) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in
|
||||
if let data {
|
||||
continuation.resume(returning: data)
|
||||
} else {
|
||||
continuation.resume(throwing: ImportError.invalidImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
3362B8F6FEB9187AF06C2973 /* file:Sources/App/RootView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "RootView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E8EB797FC7B931792536B82F /* build:Sources/App/RootView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 3362B8F6FEB9187AF06C2973 /* RootView.swift */;
|
||||
};
|
||||
09FA9998D53C167BA164487C /* file:Sources/App/StowApp.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "StowApp.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
864B5FF1FEBFBD45DF72BC12 /* build:Sources/App/StowApp.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 09FA9998D53C167BA164487C /* StowApp.swift */;
|
||||
};
|
||||
72483B00A6425B600C442BC9 /* file:Sources/DesignSystem/StowStyle.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "StowStyle.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9D610B162B1B80416AC881C /* build:Sources/DesignSystem/StowStyle.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 72483B00A6425B600C442BC9 /* StowStyle.swift */;
|
||||
};
|
||||
897F8C7966F8C33130E00F5E /* file:Sources/Features/Collection/CollectionView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "CollectionView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6934A69E9E18E3BB525BFF91 /* build:Sources/Features/Collection/CollectionView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 897F8C7966F8C33130E00F5E /* CollectionView.swift */;
|
||||
};
|
||||
7D4460FF2CB0F8CA12712B20 /* file:Sources/Features/Home/HomeView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "HomeView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B58F4DCDC7AAC8EE3F207F8E /* build:Sources/Features/Home/HomeView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 7D4460FF2CB0F8CA12712B20 /* HomeView.swift */;
|
||||
};
|
||||
2493A1FE108FCBA41C360F20 /* file:Sources/Features/Import/ImportSheet.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "ImportSheet.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
775BF6F01D11C687449C2C2C /* build:Sources/Features/Import/ImportSheet.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 2493A1FE108FCBA41C360F20 /* ImportSheet.swift */;
|
||||
};
|
||||
B40FD850E9C0416D9B12796E /* file:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "ItemDetailView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
85BF25C21CD281A060378D37 /* build:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = B40FD850E9C0416D9B12796E /* ItemDetailView.swift */;
|
||||
};
|
||||
8EF91CE90E92FE3A7C5B1977 /* file:Sources/Features/Profile/ProfileView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "ProfileView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
60C5F5DE82A76A1C5BFFAB38 /* build:Sources/Features/Profile/ProfileView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */;
|
||||
};
|
||||
56E6F9A96C68F16C2A22C62E /* file:Sources/Features/Search/SearchView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "SearchView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
785BC5ABE744416B4D6C1AEB /* build:Sources/Features/Search/SearchView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 56E6F9A96C68F16C2A22C62E /* SearchView.swift */;
|
||||
};
|
||||
577D65EF2EC9794678A7A2A6 /* file:Sources/Models/SavedItem.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "SavedItem.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D08C04907404CDAB1F64DFDA /* build:Sources/Models/SavedItem.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 577D65EF2EC9794678A7A2A6 /* SavedItem.swift */;
|
||||
};
|
||||
8A07040F49C28CF4498F3D61 /* file:Sources/Services/LocalImportService.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "LocalImportService.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F621CDD61DDDCE0C3C374485 /* build:Sources/Services/LocalImportService.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 8A07040F49C28CF4498F3D61 /* LocalImportService.swift */;
|
||||
};
|
||||
38A007151ABE87CC01A5B6E9 /* product */ = {
|
||||
isa = PBXFileReference;
|
||||
explicitFileType = wrapper.application;
|
||||
includeInIndex = 0;
|
||||
path = Stow.app;
|
||||
sourceTree = BUILT_PRODUCTS_DIR;
|
||||
};
|
||||
91468586F9881985D065A03B /* group:Sources/App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3362B8F6FEB9187AF06C2973 /* RootView.swift */,
|
||||
09FA9998D53C167BA164487C /* StowApp.swift */,
|
||||
);
|
||||
path = "App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D40CDA4BC33432C558328A29 /* group:Sources/Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
577D65EF2EC9794678A7A2A6 /* SavedItem.swift */,
|
||||
);
|
||||
path = "Models";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EF972FBDF6EB970E6AA4CC4F /* group:Sources/Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */,
|
||||
8A07040F49C28CF4498F3D61 /* LocalImportService.swift */,
|
||||
);
|
||||
path = "Services";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5327D0384DBAC03BEDE08B0C /* group:Sources/DesignSystem */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72483B00A6425B600C442BC9 /* StowStyle.swift */,
|
||||
);
|
||||
path = "DesignSystem";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3E3E6E83A02BCB9449B155AE /* group:Sources/Features/Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7D4460FF2CB0F8CA12712B20 /* HomeView.swift */,
|
||||
);
|
||||
path = "Home";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D113AB81B7A7C49B08200004 /* group:Sources/Features/Collection */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
897F8C7966F8C33130E00F5E /* CollectionView.swift */,
|
||||
);
|
||||
path = "Collection";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
24C271AB4286764A636B744D /* group:Sources/Features/Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
56E6F9A96C68F16C2A22C62E /* SearchView.swift */,
|
||||
);
|
||||
path = "Search";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
664EB509DC23723B40FA9BF4 /* group:Sources/Features/ItemDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B40FD850E9C0416D9B12796E /* ItemDetailView.swift */,
|
||||
);
|
||||
path = "ItemDetail";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6EB878375904859CAE2750D4 /* group:Sources/Features/Import */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */,
|
||||
2493A1FE108FCBA41C360F20 /* ImportSheet.swift */,
|
||||
);
|
||||
path = "Import";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0AEA2CEBCB47EB60E5E6948E /* group:Sources/Features/Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */,
|
||||
);
|
||||
path = "Profile";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9D756A79786A998CCABA280E /* group:Sources/Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3E3E6E83A02BCB9449B155AE /* Home */,
|
||||
D113AB81B7A7C49B08200004 /* Collection */,
|
||||
24C271AB4286764A636B744D /* Search */,
|
||||
664EB509DC23723B40FA9BF4 /* ItemDetail */,
|
||||
6EB878375904859CAE2750D4 /* Import */,
|
||||
0AEA2CEBCB47EB60E5E6948E /* Profile */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A9012353AFD6A66EB75D4BE2 /* group:Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
91468586F9881985D065A03B /* App */,
|
||||
D40CDA4BC33432C558328A29 /* Models */,
|
||||
EF972FBDF6EB970E6AA4CC4F /* Services */,
|
||||
5327D0384DBAC03BEDE08B0C /* DesignSystem */,
|
||||
9D756A79786A998CCABA280E /* Features */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5151B46607EBC8890D8DFCC2 /* group:Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
38A007151ABE87CC01A5B6E9 /* Stow.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
931BAC40E0989DC841B8C1D2 /* group:Main */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A9012353AFD6A66EB75D4BE2 /* Sources */,
|
||||
5151B46607EBC8890D8DFCC2 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
28BE49E1E42D704C38EEA420 /* phase:Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A924CEC2651849DCC7E97538 /* ScreenshotScanView.swift in Sources */,
|
||||
817FE3FEEED3F37B0725E260 /* ScreenshotLibraryService.swift in Sources */,
|
||||
E8EB797FC7B931792536B82F /* RootView.swift in Sources */,
|
||||
864B5FF1FEBFBD45DF72BC12 /* StowApp.swift in Sources */,
|
||||
B9D610B162B1B80416AC881C /* StowStyle.swift in Sources */,
|
||||
6934A69E9E18E3BB525BFF91 /* CollectionView.swift in Sources */,
|
||||
B58F4DCDC7AAC8EE3F207F8E /* HomeView.swift in Sources */,
|
||||
775BF6F01D11C687449C2C2C /* ImportSheet.swift in Sources */,
|
||||
85BF25C21CD281A060378D37 /* ItemDetailView.swift in Sources */,
|
||||
60C5F5DE82A76A1C5BFFAB38 /* ProfileView.swift in Sources */,
|
||||
785BC5ABE744416B4D6C1AEB /* SearchView.swift in Sources */,
|
||||
D08C04907404CDAB1F64DFDA /* SavedItem.swift in Sources */,
|
||||
F621CDD61DDDCE0C3C374485 /* LocalImportService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CA8003021206097066E984FF /* phase:Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = ();
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
E26E44B743A848DED8844ED9 /* phase:Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = ();
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1619A08764C450712864E767 /* target:Stow */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7192A84B797C3971CC4FB286 /* Build configuration list for PBXNativeTarget "Stow" */;
|
||||
buildPhases = (
|
||||
28BE49E1E42D704C38EEA420 /* Sources */,
|
||||
CA8003021206097066E984FF /* Frameworks */,
|
||||
E26E44B743A848DED8844ED9 /* Resources */,
|
||||
);
|
||||
buildRules = ();
|
||||
dependencies = ();
|
||||
name = Stow;
|
||||
productName = Stow;
|
||||
productReference = 38A007151ABE87CC01A5B6E9 /* Stow.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
98F54143AB4E86B28C3AFEE0 /* project */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1500;
|
||||
};
|
||||
buildConfigurationList = 58BC0B3212E180EF4DCCBAEC /* Build configuration list for PBXProject "Stow" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (en, Base);
|
||||
mainGroup = 931BAC40E0989DC841B8C1D2;
|
||||
productRefGroup = 5151B46607EBC8890D8DFCC2;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (1619A08764C450712864E767 /* Stow */);
|
||||
};
|
||||
A9497CE23FDDC0787512981E /* config:Project:Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_OPTIMIZATION_LEVEL = -Onone;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1D69AE994E46541145CBB141 /* config:Target:Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "收好";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A9DD297AC466B8C891118663 /* config:Project:Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_OPTIMIZATION_LEVEL = -O;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
409A63BCB0B64CF6220D29A9 /* config:Target:Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "收好";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
58BC0B3212E180EF4DCCBAEC /* configs:Project */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A9497CE23FDDC0787512981E /* Debug */,
|
||||
A9DD297AC466B8C891118663 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
7192A84B797C3971CC4FB286 /* configs:Target */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1D69AE994E46541145CBB141 /* Debug */,
|
||||
409A63BCB0B64CF6220D29A9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
266BC7E402A0C08B51E4A8F9 /* file:Sources/Services/ScreenshotLibraryService.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "ScreenshotLibraryService.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
817FE3FEEED3F37B0725E260 /* build:Sources/Services/ScreenshotLibraryService.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = 266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */;
|
||||
};
|
||||
EA66DC9AC41434DF211AE03E /* file:Sources/Features/Import/ScreenshotScanView.swift */ = {
|
||||
isa = PBXFileReference;
|
||||
lastKnownFileType = sourcecode.swift;
|
||||
path = "ScreenshotScanView.swift";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A924CEC2651849DCC7E97538 /* build:Sources/Features/Import/ScreenshotScanView.swift */ = {
|
||||
isa = PBXBuildFile;
|
||||
fileRef = EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */;
|
||||
};
|
||||
};
|
||||
rootObject = 98F54143AB4E86B28C3AFEE0 /* Project object */;
|
||||
}
|
||||
Reference in New Issue
Block a user