mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-07-28 21:38:51 +00:00
Fix gqlgen (#1105)
This commit is contained in:
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
@@ -1,20 +1,22 @@
|
||||
name: Docker builds
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master]
|
||||
branches: ['*']
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
# At 01:18 every Thursday. Details in https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule
|
||||
- cron: '18 1 * * 4'
|
||||
|
||||
env:
|
||||
IS_PUSHING_IMAGES: ${{ github.event_name != 'pull_request' && github.repository == 'photoview/photoview' }}
|
||||
# IS_PUSHING_IMAGES: true # Switch on when pushing images with forked repos.
|
||||
DOCKER_REGISTRY: ${{ (github.repository == 'photoview/photoview' && '') || 'ghcr.io/' }}
|
||||
DOCKER_USERNAME: viktorstrate
|
||||
DOCKER_IMAGE: viktorstrate/photoview
|
||||
DOCKER_IMAGE: ${{ (github.repository == 'photoview/photoview' && 'viktorstrate/photoview') || github.repository }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
||||
@@ -82,7 +84,6 @@ jobs:
|
||||
with:
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -90,15 +91,16 @@ jobs:
|
||||
if: ${{ env.IS_PUSHING_IMAGES == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_PASSWORD }}
|
||||
registry: ${{ env.DOCKER_REGISTRY == 'ghcr.io/' && 'ghcr.io' }}
|
||||
username: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.actor ) || env.DOCKER_USERNAME }}
|
||||
password: ${{ ( env.DOCKER_REGISTRY == 'ghcr.io/' && github.token ) || env.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: ${{ env.DOCKER_IMAGE }}
|
||||
images: ${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}
|
||||
# Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=schedule
|
||||
@@ -152,13 +154,13 @@ jobs:
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Run Dockle for '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||
- name: Run Dockle for '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||
id: dockle
|
||||
if: ${{ matrix.tags.tag != '' }}
|
||||
continue-on-error: true
|
||||
uses: erzz/dockle-action@v1
|
||||
with:
|
||||
image: '${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||
image: '${{ env.DOCKER_REGISTRY }}${{ env.DOCKER_IMAGE }}:${{ matrix.tags.tag }}'
|
||||
report-name: dockle-results-${{ matrix.tags.tag }}
|
||||
report-format: sarif
|
||||
failure-threshold: fatal
|
||||
|
||||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -2,7 +2,7 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
@@ -69,12 +69,13 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --name test --network host \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
-e PHOTOVIEW_DATABASE_DRIVER=${{ matrix.database }} \
|
||||
-e PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(localhost:3306)/photoview_test' \
|
||||
-e PHOTOVIEW_POSTGRES_URL='postgres://photoview:photosecret@localhost:5432/photoview_test' \
|
||||
-e PHOTOVIEW_SQLITE_PATH=/tmp/photoview.db \
|
||||
photoview/api \
|
||||
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic
|
||||
/app/scripts/test_all.sh
|
||||
docker cp test:/app/api/coverage.txt ./api/
|
||||
|
||||
- name: Upload coverage
|
||||
|
||||
@@ -68,6 +68,7 @@ COPY --from=viktorstrate/dependencies /artifacts.tar.gz /dependencies/
|
||||
# Split values in `/env`
|
||||
# hadolint ignore=SC2046
|
||||
RUN export $(cat /env) \
|
||||
&& git config --global --add safe.directory /app \
|
||||
&& cd /dependencies/ \
|
||||
&& tar xfv artifacts.tar.gz \
|
||||
&& cp -a include/* /usr/local/include/ \
|
||||
|
||||
@@ -14,7 +14,7 @@ resolver:
|
||||
layout: follow-schema
|
||||
dir: graphql/resolvers
|
||||
package: resolvers
|
||||
filename_template: "{name}.generated.go"
|
||||
filename_template: "{name}.go"
|
||||
|
||||
autobind: []
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ type Subscription struct {
|
||||
}
|
||||
|
||||
// A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||
// NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||
type TimelineGroup struct {
|
||||
// The full album containing the media in this timeline group
|
||||
Album *Album `json:"album"`
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ImageFaces is the resolver for the imageFaces field.
|
||||
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
query = models.FormatSQL(query, nil, paginate)
|
||||
|
||||
var imageFaces []*models.ImageFace
|
||||
if err := query.Find(&imageFaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imageFaces, nil
|
||||
}
|
||||
|
||||
// ImageFaceCount is the resolver for the imageFaceCount field.
|
||||
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return -1, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return -1, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.
|
||||
Model(&models.ImageFace{}).
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// Media is the resolver for the media field.
|
||||
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
||||
if err := obj.FillMedia(r.DB(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &obj.Media, nil
|
||||
}
|
||||
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
||||
if obj.FaceGroup != nil {
|
||||
return obj.FaceGroup, nil
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj.FaceGroup = &faceGroup
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
|
||||
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faceGroup, nil
|
||||
}
|
||||
|
||||
// CombineFaceGroups is the resolver for the combineFaceGroups field.
|
||||
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateError := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.ImageFace{}).
|
||||
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
|
||||
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if updateError != nil {
|
||||
return nil, updateError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
|
||||
|
||||
return destinationFaceGroup, nil
|
||||
}
|
||||
|
||||
// MoveImageFaces is the resolver for the moveImageFaces field.
|
||||
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
var destFaceGroup *models.FaceGroup
|
||||
|
||||
transErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
var err error
|
||||
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
var sourceFaceGroups []*models.FaceGroup
|
||||
if err := tx.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
|
||||
Find(&sourceFaceGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete face groups if they have become empty
|
||||
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transErr != nil {
|
||||
return nil, transErr
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
||||
|
||||
return destFaceGroup, nil
|
||||
}
|
||||
|
||||
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
|
||||
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var updatedImageFaces []*models.ImageFace
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
return updatedImageFaces, nil
|
||||
}
|
||||
|
||||
// DetachImageFaces is the resolver for the detachImageFaces field.
|
||||
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
newFaceGroup := models.FaceGroup{}
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
||||
|
||||
return &newFaceGroup, nil
|
||||
}
|
||||
|
||||
// MyFaceGroups is the resolver for the myFaceGroups field.
|
||||
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
faceGroupQuery := db.
|
||||
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where("image_faces.media_id IN (?)",
|
||||
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
|
||||
Group("image_faces.face_group_id").
|
||||
Group("face_groups.id").
|
||||
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
|
||||
Order("COUNT(image_faces.id) DESC")
|
||||
|
||||
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
|
||||
|
||||
var faceGroups []*models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faceGroups, nil
|
||||
}
|
||||
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
faceGroupQuery := db.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
|
||||
Where("face_groups.id = ?", id).
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
// FaceGroup returns api.FaceGroupResolver implementation.
|
||||
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
|
||||
|
||||
// ImageFace returns api.ImageFaceResolver implementation.
|
||||
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
|
||||
|
||||
type faceGroupResolver struct{ *Resolver }
|
||||
type imageFaceResolver struct{ *Resolver }
|
||||
@@ -1,27 +1,30 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const faceGroupIDIsQuestion = "face_group_id = ?"
|
||||
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
|
||||
const imageFacesIDInQuestion = "image_faces.id IN (?)"
|
||||
// ImageFaces is the resolver for the imageFaces field.
|
||||
func (r *faceGroupResolver) ImageFaces(ctx context.Context, obj *models.FaceGroup, paginate *models.Pagination) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
|
||||
|
||||
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
||||
if user.Admin {
|
||||
var faceGroup models.FaceGroup
|
||||
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
@@ -33,32 +36,308 @@ func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*model
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
// Verify that user owns at leat one of the images in the face group
|
||||
imageFaceQuery := db.
|
||||
Select("image_faces.id").
|
||||
Table("image_faces").
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
query := db.
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
faceGroupQuery := db.
|
||||
Model(&models.FaceGroup{}).
|
||||
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
||||
Where("face_groups.id = ?", faceGroupID).
|
||||
Where(imageFacesIDInQuestion, imageFaceQuery)
|
||||
query = models.FormatSQL(query, nil, paginate)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
|
||||
}
|
||||
var imageFaces []*models.ImageFace
|
||||
if err := query.Find(&imageFaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imageFaces, nil
|
||||
}
|
||||
|
||||
// ImageFaceCount is the resolver for the imageFaceCount field.
|
||||
func (r *faceGroupResolver) ImageFaceCount(ctx context.Context, obj *models.FaceGroup) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return -1, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return -1, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
query := db.
|
||||
Model(&models.ImageFace{}).
|
||||
Joins("Media").
|
||||
Where(faceGroupIDIsQuestion, obj.ID).
|
||||
Where("album_id IN (?)", userAlbumIDs)
|
||||
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// Media is the resolver for the media field.
|
||||
func (r *imageFaceResolver) Media(ctx context.Context, obj *models.ImageFace) (*models.Media, error) {
|
||||
if err := obj.FillMedia(r.DB(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &obj.Media, nil
|
||||
}
|
||||
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
||||
if obj.FaceGroup != nil {
|
||||
return obj.FaceGroup, nil
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := r.DB(ctx).Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj.FaceGroup = &faceGroup
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
||||
if err := user.FillAlbums(tx); err != nil {
|
||||
// SetFaceGroupLabel is the resolver for the setFaceGroupLabel field.
|
||||
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
faceGroup, err := userOwnedFaceGroup(db, user, faceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Model(faceGroup).Update("label", label).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return faceGroup, nil
|
||||
}
|
||||
|
||||
// CombineFaceGroups is the resolver for the combineFaceGroups field.
|
||||
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
destinationFaceGroup, err := userOwnedFaceGroup(db, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceFaceGroup, err := userOwnedFaceGroup(db, user, sourceFaceGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateError := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.ImageFace{}).
|
||||
Where(faceGroupIDIsQuestion, sourceFaceGroup.ID).
|
||||
Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if updateError != nil {
|
||||
return nil, updateError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
|
||||
|
||||
return destinationFaceGroup, nil
|
||||
}
|
||||
|
||||
// MoveImageFaces is the resolver for the moveImageFaces field.
|
||||
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
var destFaceGroup *models.FaceGroup
|
||||
|
||||
transErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
var err error
|
||||
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
var sourceFaceGroups []*models.FaceGroup
|
||||
if err := tx.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where(imageFacesIDInQuestion, userOwnedImageFaceIDs).
|
||||
Find(&sourceFaceGroups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete face groups if they have become empty
|
||||
if err := deleteEmptyFaceGroups(sourceFaceGroups, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transErr != nil {
|
||||
return nil, transErr
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
||||
|
||||
return destFaceGroup, nil
|
||||
}
|
||||
|
||||
// RecognizeUnlabeledFaces is the resolver for the recognizeUnlabeledFaces field.
|
||||
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
var updatedImageFaces []*models.ImageFace
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
return updatedImageFaces, nil
|
||||
}
|
||||
|
||||
// DetachImageFaces is the resolver for the detachImageFaces field.
|
||||
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
userOwnedImageFaceIDs := make([]int, 0)
|
||||
newFaceGroup := models.FaceGroup{}
|
||||
|
||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, imageFace := range userOwnedImageFaces {
|
||||
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||
}
|
||||
|
||||
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.
|
||||
Model(&models.ImageFace{}).
|
||||
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if transactionError != nil {
|
||||
return nil, transactionError
|
||||
}
|
||||
|
||||
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
||||
|
||||
return &newFaceGroup, nil
|
||||
}
|
||||
|
||||
// MyFaceGroups is the resolver for the myFaceGroups field.
|
||||
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -67,30 +346,65 @@ func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int)
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
var userOwnedImageFaces []*models.ImageFace
|
||||
if err := tx.
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs).
|
||||
Where(imageFacesIDInQuestion, imageFaceIDs).
|
||||
Find(&userOwnedImageFaces).Error; err != nil {
|
||||
faceGroupQuery := db.
|
||||
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Where("image_faces.media_id IN (?)",
|
||||
db.Select("media.id").Table("media").Where(mediaAlbumIDInQuestion, userAlbumIDs)).
|
||||
Group("image_faces.face_group_id").
|
||||
Group("face_groups.id").
|
||||
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END").
|
||||
Order("COUNT(image_faces.id) DESC")
|
||||
|
||||
faceGroupQuery = models.FormatSQL(faceGroupQuery, nil, paginate)
|
||||
|
||||
var faceGroups []*models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userOwnedImageFaces, nil
|
||||
return faceGroups, nil
|
||||
}
|
||||
|
||||
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
||||
for _, faceGroup := range sourceFaceGroups {
|
||||
var count int64
|
||||
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// FaceGroup is the resolver for the faceGroup field.
|
||||
func (r *queryResolver) FaceGroup(ctx context.Context, id int) (*models.FaceGroup, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, errors.New("unauthorized")
|
||||
}
|
||||
return nil
|
||||
|
||||
if face_detection.GlobalFaceDetector == nil {
|
||||
return nil, ErrFaceDetectorNotInitialized
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
faceGroupQuery := db.
|
||||
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||
Joins("LEFT JOIN media ON image_faces.media_id = media.id").
|
||||
Where("face_groups.id = ?", id).
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
// FaceGroup returns api.FaceGroupResolver implementation.
|
||||
func (r *Resolver) FaceGroup() api.FaceGroupResolver { return &faceGroupResolver{r} }
|
||||
|
||||
// ImageFace returns api.ImageFaceResolver implementation.
|
||||
func (r *Resolver) ImageFace() api.ImageFaceResolver { return &imageFaceResolver{r} }
|
||||
|
||||
type faceGroupResolver struct{ *Resolver }
|
||||
type imageFaceResolver struct{ *Resolver }
|
||||
|
||||
96
api/graphql/resolvers/faces.util.go
Normal file
96
api/graphql/resolvers/faces.util.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const faceGroupIDIsQuestion = "face_group_id = ?"
|
||||
const mediaAlbumIDInQuestion = "media.album_id IN (?)"
|
||||
const imageFacesIDInQuestion = "image_faces.id IN (?)"
|
||||
|
||||
var ErrFaceDetectorNotInitialized = errors.New("face detector not initialized")
|
||||
|
||||
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
||||
if user.Admin {
|
||||
var faceGroup models.FaceGroup
|
||||
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
if err := user.FillAlbums(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
// Verify that user owns at leat one of the images in the face group
|
||||
imageFaceQuery := db.
|
||||
Select("image_faces.id").
|
||||
Table("image_faces").
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs)
|
||||
|
||||
faceGroupQuery := db.
|
||||
Model(&models.FaceGroup{}).
|
||||
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
||||
Where("face_groups.id = ?", faceGroupID).
|
||||
Where(imageFacesIDInQuestion, imageFaceQuery)
|
||||
|
||||
var faceGroup models.FaceGroup
|
||||
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("face group does not exist or is not owned by the user: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &faceGroup, nil
|
||||
}
|
||||
|
||||
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
||||
if err := user.FillAlbums(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userAlbumIDs := make([]int, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
userAlbumIDs[i] = album.ID
|
||||
}
|
||||
|
||||
var userOwnedImageFaces []*models.ImageFace
|
||||
if err := tx.
|
||||
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||
Where(mediaAlbumIDInQuestion, userAlbumIDs).
|
||||
Where(imageFacesIDInQuestion, imageFaceIDs).
|
||||
Find(&userOwnedImageFaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return userOwnedImageFaces, nil
|
||||
}
|
||||
|
||||
func deleteEmptyFaceGroups(sourceFaceGroups []*models.FaceGroup, tx *gorm.DB) error {
|
||||
for _, faceGroup := range sourceFaceGroups {
|
||||
var count int64
|
||||
if err := tx.Model(&models.ImageFace{}).Where(faceGroupIDIsQuestion, faceGroup.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -20,6 +20,14 @@ enum MediaType {
|
||||
Video
|
||||
}
|
||||
|
||||
type Coordinates {
|
||||
"GPS latitude in degrees"
|
||||
latitude: Float!
|
||||
"GPS longitude in degrees"
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
|
||||
"EXIF metadata from the camera"
|
||||
type MediaEXIF {
|
||||
id: ID!
|
||||
@@ -49,6 +57,20 @@ type MediaEXIF {
|
||||
coordinates: Coordinates
|
||||
}
|
||||
|
||||
"Metadata specific to video media"
|
||||
type VideoMetadata {
|
||||
id: ID!
|
||||
media: Media!
|
||||
width: Int!
|
||||
height: Int!
|
||||
duration: Float!
|
||||
codec: String
|
||||
framerate: Float
|
||||
bitrate: String
|
||||
colorProfile: String
|
||||
audio: String
|
||||
}
|
||||
|
||||
type Media {
|
||||
id: ID!
|
||||
title: String!
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
|
||||
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var media []*geoMedia
|
||||
|
||||
err := r.DB(ctx).Table("media").
|
||||
Select("media.id AS media_id, media.title AS media_title, "+
|
||||
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
|
||||
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
|
||||
"media_exif.gps_longitude AS longitude").
|
||||
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
|
||||
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
|
||||
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
|
||||
Where("media_exif.gps_latitude IS NOT NULL").
|
||||
Where("media_exif.gps_longitude IS NOT NULL").
|
||||
Where("media_urls.purpose = 'thumbnail'").
|
||||
Where("user_albums.user_id = ?", user.ID).
|
||||
Scan(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
features := make([]geoJSONFeature, 0)
|
||||
|
||||
for _, item := range media {
|
||||
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
|
||||
|
||||
thumbnailURL := utils.ApiEndpointUrl()
|
||||
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
|
||||
|
||||
properties := geoJSONMediaProperties{
|
||||
MediaID: item.MediaID,
|
||||
MediaTitle: item.MediaTitle,
|
||||
Thumbnail: struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}{
|
||||
URL: thumbnailURL.String(),
|
||||
Width: item.ThumbnailWidth,
|
||||
Height: item.ThumbnailHeight,
|
||||
},
|
||||
}
|
||||
|
||||
features = append(features, makeGeoJSONFeature(properties, geoPoint))
|
||||
}
|
||||
|
||||
featureCollection := makeGeoJSONFeatureCollection(features)
|
||||
return featureCollection, nil
|
||||
}
|
||||
|
||||
// MapboxToken is the resolver for the mapboxToken field.
|
||||
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
|
||||
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
|
||||
if mapboxTokenEnv == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &mapboxTokenEnv, nil
|
||||
}
|
||||
@@ -1,61 +1,80 @@
|
||||
package resolvers
|
||||
|
||||
type geoMedia struct {
|
||||
MediaID int
|
||||
MediaTitle string
|
||||
ThumbnailName string
|
||||
ThumbnailWidth int
|
||||
ThumbnailHeight int
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
type geoJSONFeatureCollection struct {
|
||||
Type string `json:"type"`
|
||||
Features []geoJSONFeature `json:"features"`
|
||||
}
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
type geoJSONFeature struct {
|
||||
Type string `json:"type"`
|
||||
Properties interface{} `json:"properties"`
|
||||
Geometry geoJSONFeatureGeometry `json:"geometry"`
|
||||
}
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
)
|
||||
|
||||
type geoJSONMediaProperties struct {
|
||||
MediaID int `json:"media_id"`
|
||||
MediaTitle string `json:"media_title"`
|
||||
Thumbnail struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
}
|
||||
|
||||
type geoJSONFeatureGeometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
|
||||
return geoJSONFeatureCollection{
|
||||
Type: "FeatureCollection",
|
||||
Features: features,
|
||||
// MyMediaGeoJSON is the resolver for the myMediaGeoJson field.
|
||||
func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (any, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
|
||||
return geoJSONFeature{
|
||||
Type: "Feature",
|
||||
Properties: properties,
|
||||
Geometry: geometry,
|
||||
var media []*geoMedia
|
||||
|
||||
err := r.DB(ctx).Table("media").
|
||||
Select("media.id AS media_id, media.title AS media_title, "+
|
||||
"media_urls.media_name AS thumbnail_name, media_urls.width AS thumbnail_width, "+
|
||||
"media_urls.height AS thumbnail_height, media_exif.gps_latitude AS latitude, "+
|
||||
"media_exif.gps_longitude AS longitude").
|
||||
Joins("INNER JOIN media_exif ON media.exif_id = media_exif.id").
|
||||
Joins("INNER JOIN media_urls ON media.id = media_urls.media_id").
|
||||
Joins("INNER JOIN user_albums ON media.album_id = user_albums.album_id").
|
||||
Where("media_exif.gps_latitude IS NOT NULL").
|
||||
Where("media_exif.gps_longitude IS NOT NULL").
|
||||
Where("media_urls.purpose = 'thumbnail'").
|
||||
Where("user_albums.user_id = ?", user.ID).
|
||||
Scan(&media).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
|
||||
coordinates := [2]float64{long, lat}
|
||||
features := make([]geoJSONFeature, 0)
|
||||
|
||||
return geoJSONFeatureGeometry{
|
||||
Type: "Point",
|
||||
Coordinates: coordinates,
|
||||
for _, item := range media {
|
||||
geoPoint := makeGeoJSONFeatureGeometryPoint(item.Latitude, item.Longitude)
|
||||
|
||||
thumbnailURL := utils.ApiEndpointUrl()
|
||||
thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", item.ThumbnailName)
|
||||
|
||||
properties := geoJSONMediaProperties{
|
||||
MediaID: item.MediaID,
|
||||
MediaTitle: item.MediaTitle,
|
||||
Thumbnail: struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}{
|
||||
URL: thumbnailURL.String(),
|
||||
Width: item.ThumbnailWidth,
|
||||
Height: item.ThumbnailHeight,
|
||||
},
|
||||
}
|
||||
|
||||
features = append(features, makeGeoJSONFeature(properties, geoPoint))
|
||||
}
|
||||
|
||||
featureCollection := makeGeoJSONFeatureCollection(features)
|
||||
return featureCollection, nil
|
||||
}
|
||||
|
||||
// MapboxToken is the resolver for the mapboxToken field.
|
||||
func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) {
|
||||
mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN")
|
||||
if mapboxTokenEnv == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &mapboxTokenEnv, nil
|
||||
}
|
||||
|
||||
61
api/graphql/resolvers/media_geo_json.util.go
Normal file
61
api/graphql/resolvers/media_geo_json.util.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package resolvers
|
||||
|
||||
type geoMedia struct {
|
||||
MediaID int
|
||||
MediaTitle string
|
||||
ThumbnailName string
|
||||
ThumbnailWidth int
|
||||
ThumbnailHeight int
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
type geoJSONFeatureCollection struct {
|
||||
Type string `json:"type"`
|
||||
Features []geoJSONFeature `json:"features"`
|
||||
}
|
||||
|
||||
type geoJSONFeature struct {
|
||||
Type string `json:"type"`
|
||||
Properties interface{} `json:"properties"`
|
||||
Geometry geoJSONFeatureGeometry `json:"geometry"`
|
||||
}
|
||||
|
||||
type geoJSONMediaProperties struct {
|
||||
MediaID int `json:"media_id"`
|
||||
MediaTitle string `json:"media_title"`
|
||||
Thumbnail struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"thumbnail"`
|
||||
}
|
||||
|
||||
type geoJSONFeatureGeometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection {
|
||||
return geoJSONFeatureCollection{
|
||||
Type: "FeatureCollection",
|
||||
Features: features,
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature {
|
||||
return geoJSONFeature{
|
||||
Type: "Feature",
|
||||
Properties: properties,
|
||||
Geometry: geometry,
|
||||
}
|
||||
}
|
||||
|
||||
func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry {
|
||||
coordinates := [2]float64{long, lat}
|
||||
|
||||
return geoJSONFeatureGeometry{
|
||||
Type: "Point",
|
||||
Coordinates: coordinates,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/notification"
|
||||
)
|
||||
|
||||
// Notification is the resolver for the notification field.
|
||||
func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models.Notification, error) {
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
@@ -26,3 +31,8 @@ func (r *subscriptionResolver) Notification(ctx context.Context) (<-chan *models
|
||||
|
||||
return notificationChannel, nil
|
||||
}
|
||||
|
||||
// Subscription returns api.SubscriptionResolver implementation.
|
||||
func (r *Resolver) Subscription() api.SubscriptionResolver { return &subscriptionResolver{r} }
|
||||
|
||||
type subscriptionResolver struct{ *Resolver }
|
||||
|
||||
31
api/graphql/resolvers/notification.graphql
Normal file
31
api/graphql/resolvers/notification.graphql
Normal file
@@ -0,0 +1,31 @@
|
||||
type Notification {
|
||||
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
|
||||
key: String!
|
||||
type: NotificationType!
|
||||
"The text for the title of the notification"
|
||||
header: String!
|
||||
"The text for the body of the notification"
|
||||
content: String!
|
||||
"A value between 0 and 1 when the notification type is `Progress`"
|
||||
progress: Float
|
||||
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
|
||||
positive: Boolean!
|
||||
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
|
||||
negative: Boolean!
|
||||
"Time in milliseconds before the notification should close"
|
||||
timeout: Int
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
notification: Notification!
|
||||
}
|
||||
|
||||
"Specified the type a particular notification is of"
|
||||
enum NotificationType {
|
||||
"A regular message with no special additions"
|
||||
Message
|
||||
"A notification with an attached progress indicator"
|
||||
Progress
|
||||
"Close a notification with a given key"
|
||||
Close
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package resolvers
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -23,25 +22,3 @@ func NewRootResolver(db *gorm.DB) Resolver {
|
||||
func (r *Resolver) DB(ctx context.Context) *gorm.DB {
|
||||
return r.database.WithContext(ctx)
|
||||
}
|
||||
|
||||
func (r *Resolver) Mutation() api.MutationResolver {
|
||||
return &mutationResolver{r}
|
||||
}
|
||||
|
||||
func (r *Resolver) Query() api.QueryResolver {
|
||||
return &queryResolver{r}
|
||||
}
|
||||
|
||||
func (r *Resolver) Subscription() api.SubscriptionResolver {
|
||||
return &subscriptionResolver{
|
||||
Resolver: r,
|
||||
}
|
||||
}
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
||||
|
||||
type queryResolver struct{ *Resolver }
|
||||
|
||||
type subscriptionResolver struct {
|
||||
Resolver *Resolver
|
||||
}
|
||||
|
||||
18
api/graphql/resolvers/root.go
Normal file
18
api/graphql/resolvers/root.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
)
|
||||
|
||||
// Mutation returns api.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
// Query returns api.QueryResolver implementation.
|
||||
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} }
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
47
api/graphql/resolvers/root.graphql
Normal file
47
api/graphql/resolvers/root.graphql
Normal file
@@ -0,0 +1,47 @@
|
||||
directive @isAuthorized on FIELD_DEFINITION
|
||||
directive @isAdmin on FIELD_DEFINITION
|
||||
|
||||
scalar Time
|
||||
scalar Any
|
||||
|
||||
"Used to specify which order to sort items in"
|
||||
enum OrderDirection {
|
||||
"Sort accending A-Z"
|
||||
ASC
|
||||
"Sort decending Z-A"
|
||||
DESC
|
||||
}
|
||||
|
||||
"Used to specify pagination on a list of items"
|
||||
input Pagination {
|
||||
"How many items to maximally fetch"
|
||||
limit: Int
|
||||
"How many items to skip from the beginning of the query, specified by the `Ordering`"
|
||||
offset: Int
|
||||
}
|
||||
|
||||
"Used to specify how to sort items"
|
||||
input Ordering {
|
||||
"A column in the database to order by"
|
||||
order_by: String
|
||||
order_direction: OrderDirection
|
||||
}
|
||||
|
||||
type Query
|
||||
|
||||
type Mutation
|
||||
|
||||
"""
|
||||
A group of media from the same album and the same day, that is grouped together in a timeline view
|
||||
NOTE: It isn't used. Just copy from the old schema.graphql.
|
||||
"""
|
||||
type TimelineGroup {
|
||||
"The full album containing the media in this timeline group"
|
||||
album: Album!
|
||||
"The media contained in this timeline group"
|
||||
media: [Media!]!
|
||||
"The total amount of media in this timeline group"
|
||||
mediaTotal: Int!
|
||||
"The day shared for all media in this timeline group"
|
||||
date: Time!
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/photoview/photoview/api/database/drivers"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/periodic_scanner"
|
||||
"github.com/photoview/photoview/api/scanner/scanner_queue"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ScanAll is the resolver for the scanAll field.
|
||||
func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, error) {
|
||||
err := scanner_queue.AddAllToQueue()
|
||||
if err != nil {
|
||||
@@ -27,10 +33,11 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScanUser is the resolver for the scanUser field.
|
||||
func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) {
|
||||
var user models.User
|
||||
if err := r.DB(ctx).First(&user, userID).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get user from database")
|
||||
return nil, fmt.Errorf("get user from database: %w", err)
|
||||
}
|
||||
|
||||
scanner_queue.AddUserToQueue(&user)
|
||||
@@ -43,6 +50,7 @@ func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.Sc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetPeriodicScanInterval is the resolver for the setPeriodicScanInterval field.
|
||||
func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
if interval < 0 {
|
||||
@@ -68,6 +76,7 @@ func (r *mutationResolver) SetPeriodicScanInterval(ctx context.Context, interval
|
||||
return siteInfo.PeriodicScanInterval, nil
|
||||
}
|
||||
|
||||
// SetScannerConcurrentWorkers is the resolver for the setScannerConcurrentWorkers field.
|
||||
func (r *mutationResolver) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) {
|
||||
db := r.DB(ctx)
|
||||
if workers < 1 {
|
||||
|
||||
23
api/graphql/resolvers/scanner.graphql
Normal file
23
api/graphql/resolvers/scanner.graphql
Normal file
@@ -0,0 +1,23 @@
|
||||
type ScannerResult {
|
||||
finished: Boolean!
|
||||
success: Boolean!
|
||||
progress: Float
|
||||
message: String
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Scan all users for new media"
|
||||
scanAll: ScannerResult! @isAdmin
|
||||
|
||||
"Scan a single user for new media"
|
||||
scanUser(userId: ID!): ScannerResult! @isAdmin
|
||||
|
||||
"""
|
||||
Set how often, in seconds, the server should automatically scan for new media,
|
||||
a value of 0 will disable periodic scans
|
||||
"""
|
||||
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
|
||||
|
||||
"Set max number of concurrent scanner jobs running at once"
|
||||
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
directive @isAuthorized on FIELD_DEFINITION
|
||||
directive @isAdmin on FIELD_DEFINITION
|
||||
|
||||
scalar Time
|
||||
scalar Any
|
||||
|
||||
"Used to specify which order to sort items in"
|
||||
enum OrderDirection {
|
||||
"Sort accending A-Z"
|
||||
ASC
|
||||
"Sort decending Z-A"
|
||||
DESC
|
||||
}
|
||||
|
||||
"Used to specify pagination on a list of items"
|
||||
input Pagination {
|
||||
"How many items to maximally fetch"
|
||||
limit: Int
|
||||
"How many items to skip from the beginning of the query, specified by the `Ordering`"
|
||||
offset: Int
|
||||
}
|
||||
|
||||
"Used to specify how to sort items"
|
||||
input Ordering {
|
||||
"A column in the database to order by"
|
||||
order_by: String
|
||||
order_direction: OrderDirection
|
||||
}
|
||||
|
||||
"Credentials used to identify and authenticate a share token"
|
||||
input ShareTokenCredentials {
|
||||
token: String!
|
||||
password: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
siteInfo: SiteInfo!
|
||||
|
||||
"List of registered users, must be admin to call"
|
||||
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
|
||||
"Information about the currently logged in user"
|
||||
myUser: User! @isAuthorized
|
||||
|
||||
"User preferences for the logged in user"
|
||||
myUserPreferences: UserPreferences! @isAuthorized
|
||||
|
||||
"""
|
||||
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
|
||||
"""
|
||||
myTimeline(
|
||||
paginate: Pagination,
|
||||
onlyFavorites: Boolean,
|
||||
"Only fetch media that is older than this date"
|
||||
fromDate: Time
|
||||
): [Media!]! @isAuthorized
|
||||
|
||||
"Fetch a share token containing an `Album` or `Media`"
|
||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||
"Check if the `ShareToken` credentials are valid"
|
||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||
|
||||
"Perform a search query on the contents of the media library"
|
||||
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
"Authorizes a user and returns a token used to identify the new session"
|
||||
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
||||
|
||||
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
|
||||
initialSetupWizard(
|
||||
username: String!
|
||||
password: String!
|
||||
rootPath: String!
|
||||
): AuthorizeResult
|
||||
|
||||
"Scan all users for new media"
|
||||
scanAll: ScannerResult! @isAdmin
|
||||
"Scan a single user for new media"
|
||||
scanUser(userId: ID!): ScannerResult! @isAdmin
|
||||
|
||||
"Generate share token for album"
|
||||
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
"Generate share token for media"
|
||||
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
"Delete a share token by it's token value"
|
||||
deleteShareToken(token: String!): ShareToken! @isAuthorized
|
||||
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
|
||||
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Update a user, fields left as `null` will not be changed"
|
||||
updateUser(
|
||||
id: ID!
|
||||
username: String
|
||||
password: String
|
||||
admin: Boolean
|
||||
): User! @isAdmin
|
||||
"Create a new user"
|
||||
createUser(
|
||||
username: String!
|
||||
password: String
|
||||
admin: Boolean!
|
||||
): User! @isAdmin
|
||||
"Delete an existing user"
|
||||
deleteUser(id: ID!): User! @isAdmin
|
||||
|
||||
"Add a root path from where to look for media for the given user, specified by their user id."
|
||||
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
|
||||
"""
|
||||
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
||||
This album was returned when creating the path using `userAddRootPath`.
|
||||
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
||||
"""
|
||||
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
|
||||
|
||||
"""
|
||||
Set how often, in seconds, the server should automatically scan for new media,
|
||||
a value of 0 will disable periodic scans
|
||||
"""
|
||||
setPeriodicScanInterval(interval: Int!): Int! @isAdmin
|
||||
|
||||
"Set max number of concurrent scanner jobs running at once"
|
||||
setScannerConcurrentWorkers(workers: Int!): Int! @isAdmin
|
||||
|
||||
"Set the filter to be used when generating thumbnails"
|
||||
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
|
||||
|
||||
"Change user preferences for the logged in user"
|
||||
changeUserPreferences(language: String): UserPreferences! @isAuthorized
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
notification: Notification!
|
||||
}
|
||||
|
||||
"Specified the type a particular notification is of"
|
||||
enum NotificationType {
|
||||
"A regular message with no special additions"
|
||||
Message
|
||||
"A notification with an attached progress indicator"
|
||||
Progress
|
||||
"Close a notification with a given key"
|
||||
Close
|
||||
}
|
||||
|
||||
type Notification {
|
||||
"A key used to identify the notification, new notification updates with the same key, should replace the old notifications"
|
||||
key: String!
|
||||
type: NotificationType!
|
||||
"The text for the title of the notification"
|
||||
header: String!
|
||||
"The text for the body of the notification"
|
||||
content: String!
|
||||
"A value between 0 and 1 when the notification type is `Progress`"
|
||||
progress: Float
|
||||
"Whether or not the message of the notification is positive, the UI might reflect this with a green color"
|
||||
positive: Boolean!
|
||||
"Whether or not the message of the notification is negative, the UI might reflect this with a red color"
|
||||
negative: Boolean!
|
||||
"Time in milliseconds before the notification should close"
|
||||
timeout: Int
|
||||
}
|
||||
|
||||
type AuthorizeResult {
|
||||
success: Boolean!
|
||||
"A textual status message describing the result, can be used to show an error message when `success` is false"
|
||||
status: String!
|
||||
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
|
||||
token: String
|
||||
}
|
||||
|
||||
type ScannerResult {
|
||||
finished: Boolean!
|
||||
success: Boolean!
|
||||
progress: Float
|
||||
message: String
|
||||
}
|
||||
|
||||
"A token used to publicly access an album or media"
|
||||
type ShareToken {
|
||||
id: ID!
|
||||
token: String!
|
||||
"The user who created the token"
|
||||
owner: User!
|
||||
"Optional expire date"
|
||||
expire: Time
|
||||
"Whether or not a password is needed to access the share"
|
||||
hasPassword: Boolean!
|
||||
|
||||
"The album this token shares"
|
||||
album: Album
|
||||
"The media this token shares"
|
||||
media: Media
|
||||
}
|
||||
|
||||
"Supported downsampling filters for thumbnail generation"
|
||||
enum ThumbnailFilter {
|
||||
NearestNeighbor,
|
||||
Box,
|
||||
Linear,
|
||||
MitchellNetravali,
|
||||
CatmullRom,
|
||||
Lanczos,
|
||||
}
|
||||
|
||||
"General information about the site"
|
||||
type SiteInfo {
|
||||
"Whether or not the initial setup wizard should be shown"
|
||||
initialSetup: Boolean!
|
||||
"Whether or not face detection is enabled and working"
|
||||
faceDetectionEnabled: Boolean!
|
||||
"How often automatic scans should be initiated in seconds"
|
||||
periodicScanInterval: Int! @isAdmin
|
||||
"How many max concurrent scanner jobs that should run at once"
|
||||
concurrentWorkers: Int! @isAdmin
|
||||
"The filter to use when generating thumbnails"
|
||||
thumbnailMethod: ThumbnailFilter! @isAdmin
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
username: String!
|
||||
"All albums owned by this user"
|
||||
albums: [Album!]! @isAdmin
|
||||
"Top level albums owned by this user"
|
||||
rootAlbums: [Album!]! @isAdmin
|
||||
"Whether or not the user has admin privileges"
|
||||
admin: Boolean!
|
||||
}
|
||||
|
||||
"Supported language translations of the user interface"
|
||||
enum LanguageTranslation {
|
||||
English,
|
||||
French,
|
||||
Italian,
|
||||
Swedish,
|
||||
Danish,
|
||||
Spanish,
|
||||
Polish,
|
||||
Ukrainian,
|
||||
German,
|
||||
Russian,
|
||||
TraditionalChinese,
|
||||
SimplifiedChinese,
|
||||
Portuguese,
|
||||
Basque,
|
||||
Turkish
|
||||
}
|
||||
|
||||
"Preferences for regular users"
|
||||
type UserPreferences {
|
||||
id: ID!
|
||||
language: LanguageTranslation
|
||||
}
|
||||
|
||||
type Coordinates {
|
||||
"GPS latitude in degrees"
|
||||
latitude: Float!
|
||||
"GPS longitude in degrees"
|
||||
longitude: Float!
|
||||
}
|
||||
|
||||
"Metadata specific to video media"
|
||||
type VideoMetadata {
|
||||
id: ID!
|
||||
media: Media!
|
||||
width: Int!
|
||||
height: Int!
|
||||
duration: Float!
|
||||
codec: String
|
||||
framerate: Float
|
||||
bitrate: String
|
||||
colorProfile: String
|
||||
audio: String
|
||||
}
|
||||
|
||||
type SearchResult {
|
||||
"The string that was searched for"
|
||||
query: String!
|
||||
"A list of albums that matched the query"
|
||||
albums: [Album!]!
|
||||
"A list of media that matched the query"
|
||||
media: [Media!]!
|
||||
}
|
||||
|
||||
"A group of media from the same album and the same day, that is grouped together in a timeline view"
|
||||
type TimelineGroup {
|
||||
"The full album containing the media in this timeline group"
|
||||
album: Album!
|
||||
"The media contained in this timeline group"
|
||||
media: [Media!]!
|
||||
"The total amount of media in this timeline group"
|
||||
mediaTotal: Int!
|
||||
"The day shared for all media in this timeline group"
|
||||
date: Time!
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
)
|
||||
|
||||
func (r *Resolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||
|
||||
// Search is the resolver for the search field.
|
||||
func (r *queryResolver) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
|
||||
13
api/graphql/resolvers/search.graphql
Normal file
13
api/graphql/resolvers/search.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
type SearchResult {
|
||||
"The string that was searched for"
|
||||
query: String!
|
||||
"A list of albums that matched the query"
|
||||
albums: [Album!]!
|
||||
"A list of media that matched the query"
|
||||
media: [Media!]!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"Perform a search query on the contents of the media library"
|
||||
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
||||
}
|
||||
@@ -1,53 +1,72 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type shareTokenResolver struct {
|
||||
*Resolver
|
||||
// ShareAlbum is the resolver for the shareAlbum field.
|
||||
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
|
||||
}
|
||||
|
||||
func (r *Resolver) ShareToken() api.ShareTokenResolver {
|
||||
return &shareTokenResolver{r}
|
||||
// ShareMedia is the resolver for the shareMedia field.
|
||||
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
|
||||
}
|
||||
|
||||
func (r *shareTokenResolver) Owner(ctx context.Context, obj *models.ShareToken) (*models.User, error) {
|
||||
return &obj.Owner, nil
|
||||
// DeleteShareToken is the resolver for the deleteShareToken field.
|
||||
func (r *mutationResolver) DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.DeleteShareToken(r.DB(ctx), user.ID, token)
|
||||
}
|
||||
|
||||
func (r *shareTokenResolver) Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error) {
|
||||
return obj.Album, nil
|
||||
}
|
||||
|
||||
func (r *shareTokenResolver) Media(ctx context.Context, obj *models.ShareToken) (*models.Media, error) {
|
||||
return obj.Media, nil
|
||||
}
|
||||
|
||||
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
|
||||
hasPassword := obj.Password != nil
|
||||
return hasPassword, nil
|
||||
// ProtectShareToken is the resolver for the protectShareToken field.
|
||||
func (r *mutationResolver) ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.ProtectShareToken(r.DB(ctx), user.ID, token, password)
|
||||
}
|
||||
|
||||
// ShareToken is the resolver for the shareToken field.
|
||||
func (r *queryResolver) ShareToken(ctx context.Context, credentials models.ShareTokenCredentials) (*models.ShareToken, error) {
|
||||
|
||||
var token models.ShareToken
|
||||
if err := r.DB(ctx).Preload(clause.Associations).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("share not found")
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "failed to get share token from database")
|
||||
return nil, fmt.Errorf("failed to get share token from database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +75,7 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return nil, errors.New("unauthorized")
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "failed to compare token password hashes")
|
||||
return nil, fmt.Errorf("failed to compare token password hashes: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,14 +83,14 @@ func (r *queryResolver) ShareToken(ctx context.Context, credentials models.Share
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// ShareTokenValidatePassword is the resolver for the shareTokenValidatePassword field.
|
||||
func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credentials models.ShareTokenCredentials) (bool, error) {
|
||||
|
||||
var token models.ShareToken
|
||||
if err := r.DB(ctx).Where("value = ?", credentials.Token).First(&token).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, errors.New("share not found")
|
||||
} else {
|
||||
return false, errors.Wrap(err, "failed to get share token from database")
|
||||
return false, fmt.Errorf("failed to get share token from database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,50 +106,20 @@ func (r *queryResolver) ShareTokenValidatePassword(ctx context.Context, credenti
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
return false, nil
|
||||
} else {
|
||||
return false, errors.Wrap(err, "could not compare token password hashes")
|
||||
return false, fmt.Errorf("could not compare token password hashes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire *time.Time,
|
||||
password *string) (*models.ShareToken, error) {
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddAlbumShare(r.DB(ctx), user, albumID, expire, password)
|
||||
// HasPassword is the resolver for the hasPassword field.
|
||||
func (r *shareTokenResolver) HasPassword(ctx context.Context, obj *models.ShareToken) (bool, error) {
|
||||
hasPassword := obj.Password != nil
|
||||
return hasPassword, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire *time.Time,
|
||||
password *string) (*models.ShareToken, error) {
|
||||
// ShareToken returns api.ShareTokenResolver implementation.
|
||||
func (r *Resolver) ShareToken() api.ShareTokenResolver { return &shareTokenResolver{r} }
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.AddMediaShare(r.DB(ctx), user, mediaID, expire, password)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DeleteShareToken(ctx context.Context, tokenValue string) (*models.ShareToken, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.DeleteShareToken(r.DB(ctx), user.ID, tokenValue)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ProtectShareToken(ctx context.Context, tokenValue string, password *string) (*models.ShareToken, error) {
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return actions.ProtectShareToken(r.DB(ctx), user.ID, tokenValue, password)
|
||||
}
|
||||
type shareTokenResolver struct{ *Resolver }
|
||||
|
||||
44
api/graphql/resolvers/share_token.graphql
Normal file
44
api/graphql/resolvers/share_token.graphql
Normal file
@@ -0,0 +1,44 @@
|
||||
"Credentials used to identify and authenticate a share token"
|
||||
input ShareTokenCredentials {
|
||||
token: String!
|
||||
password: String
|
||||
}
|
||||
|
||||
"A token used to publicly access an album or media"
|
||||
type ShareToken {
|
||||
id: ID!
|
||||
token: String!
|
||||
"The user who created the token"
|
||||
owner: User!
|
||||
"Optional expire date"
|
||||
expire: Time
|
||||
"Whether or not a password is needed to access the share"
|
||||
hasPassword: Boolean!
|
||||
|
||||
"The album this token shares"
|
||||
album: Album
|
||||
"The media this token shares"
|
||||
media: Media
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
"Fetch a share token containing an `Album` or `Media`"
|
||||
shareToken(credentials: ShareTokenCredentials!): ShareToken!
|
||||
|
||||
"Check if the `ShareToken` credentials are valid"
|
||||
shareTokenValidatePassword(credentials: ShareTokenCredentials!): Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Generate share token for album"
|
||||
shareAlbum(albumId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Generate share token for media"
|
||||
shareMedia(mediaId: ID!, expire: Time, password: String): ShareToken! @isAuthorized
|
||||
|
||||
"Delete a share token by it's token value"
|
||||
deleteShareToken(token: String!): ShareToken! @isAuthorized
|
||||
|
||||
"Set a password for a token, if null is passed for the password argument, the password will be cleared"
|
||||
protectShareToken(token: String!, password: String): ShareToken! @isAuthorized
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -8,18 +12,17 @@ import (
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
)
|
||||
|
||||
// SiteInfo is the resolver for the siteInfo field.
|
||||
func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) {
|
||||
return models.GetSiteInfo(r.DB(ctx))
|
||||
}
|
||||
|
||||
type SiteInfoResolver struct {
|
||||
*Resolver
|
||||
}
|
||||
|
||||
func (r *Resolver) SiteInfo() api.SiteInfoResolver {
|
||||
return &SiteInfoResolver{r}
|
||||
}
|
||||
|
||||
func (SiteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
|
||||
// FaceDetectionEnabled is the resolver for the faceDetectionEnabled field.
|
||||
func (r *siteInfoResolver) FaceDetectionEnabled(ctx context.Context, obj *models.SiteInfo) (bool, error) {
|
||||
return face_detection.GlobalFaceDetector != nil, nil
|
||||
}
|
||||
|
||||
// SiteInfo returns api.SiteInfoResolver implementation.
|
||||
func (r *Resolver) SiteInfo() api.SiteInfoResolver { return &siteInfoResolver{r} }
|
||||
|
||||
type siteInfoResolver struct{ *Resolver }
|
||||
|
||||
17
api/graphql/resolvers/site_info.graphql
Normal file
17
api/graphql/resolvers/site_info.graphql
Normal file
@@ -0,0 +1,17 @@
|
||||
"General information about the site"
|
||||
type SiteInfo {
|
||||
"Whether or not the initial setup wizard should be shown"
|
||||
initialSetup: Boolean!
|
||||
"Whether or not face detection is enabled and working"
|
||||
faceDetectionEnabled: Boolean!
|
||||
"How often automatic scans should be initiated in seconds"
|
||||
periodicScanInterval: Int! @isAdmin
|
||||
"How many max concurrent scanner jobs that should run at once"
|
||||
concurrentWorkers: Int! @isAdmin
|
||||
"The filter to use when generating thumbnails"
|
||||
thumbnailMethod: ThumbnailFilter! @isAdmin
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
siteInfo: SiteInfo!
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
// "github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
|
||||
method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
|
||||
|
||||
// SetThumbnailDownsampleMethod is the resolver for the setThumbnailDownsampleMethod field.
|
||||
func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context, method models.ThumbnailFilter) (models.ThumbnailFilter, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
// if method > 5 {
|
||||
@@ -38,5 +40,4 @@ func (r *mutationResolver) SetThumbnailDownsampleMethod(ctx context.Context,
|
||||
// lng := models.LanguageTranslation(*language)
|
||||
// langTrans = &lng
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
14
api/graphql/resolvers/thumbnails.graphql
Normal file
14
api/graphql/resolvers/thumbnails.graphql
Normal file
@@ -0,0 +1,14 @@
|
||||
"Supported downsampling filters for thumbnail generation"
|
||||
enum ThumbnailFilter {
|
||||
NearestNeighbor,
|
||||
Box,
|
||||
Linear,
|
||||
MitchellNetravali,
|
||||
CatmullRom,
|
||||
Lanczos,
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Set the filter to be used when generating thumbnails"
|
||||
setThumbnailDownsampleMethod(method: ThumbnailFilter!): ThumbnailFilter! @isAdmin
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
@@ -9,9 +13,8 @@ import (
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
)
|
||||
|
||||
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool,
|
||||
fromDate *time.Time) ([]*models.Media, error) {
|
||||
|
||||
// MyTimeline is the resolver for the myTimeline field.
|
||||
func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
|
||||
11
api/graphql/resolvers/timeline.graphql
Normal file
11
api/graphql/resolvers/timeline.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
extend type Query {
|
||||
"""
|
||||
Get a list of media, ordered first by day, then by album if multiple media was found for the same day.
|
||||
"""
|
||||
myTimeline(
|
||||
paginate: Pagination,
|
||||
onlyFavorites: Boolean,
|
||||
"Only fetch media that is older than this date"
|
||||
fromDate: Time
|
||||
): [Media!]! @isAuthorized
|
||||
}
|
||||
@@ -1,80 +1,26 @@
|
||||
package resolvers
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.55
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
api "github.com/photoview/photoview/api/graphql"
|
||||
"github.com/photoview/photoview/api/graphql/auth"
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/graphql/models/actions"
|
||||
"github.com/photoview/photoview/api/scanner"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type userResolver struct {
|
||||
*Resolver
|
||||
}
|
||||
|
||||
func (r *Resolver) User() api.UserResolver {
|
||||
return &userResolver{r}
|
||||
}
|
||||
|
||||
func (r *queryResolver) User(ctx context.Context, order *models.Ordering,
|
||||
paginate *models.Pagination) ([]*models.User, error) {
|
||||
|
||||
var users []*models.User
|
||||
|
||||
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *userResolver) Albums(ctx context.Context, user *models.User) ([]*models.Album, error) {
|
||||
user.FillAlbums(r.DB(ctx))
|
||||
|
||||
pointerAlbums := make([]*models.Album, len(user.Albums))
|
||||
for i, album := range user.Albums {
|
||||
pointerAlbums[i] = &album
|
||||
}
|
||||
|
||||
return pointerAlbums, nil
|
||||
}
|
||||
|
||||
func (r *userResolver) RootAlbums(ctx context.Context, user *models.User) (albums []*models.Album, err error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
err = db.Model(&user).
|
||||
Where("albums.parent_album_id NOT IN (?)",
|
||||
db.Table("user_albums").
|
||||
Select("albums.id").
|
||||
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", user.ID),
|
||||
).Or("albums.parent_album_id IS NULL").Order("path ASC").
|
||||
Association("Albums").Find(&albums)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
|
||||
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
|
||||
password string) (*models.AuthorizeResult, error) {
|
||||
// AuthorizeUser is the resolver for the authorizeUser field.
|
||||
func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) {
|
||||
db := r.DB(ctx)
|
||||
user, err := models.AuthorizeUser(db, username, password)
|
||||
if err != nil {
|
||||
@@ -106,8 +52,8 @@ func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string,
|
||||
rootPath string) (*models.AuthorizeResult, error) {
|
||||
// InitialSetupWizard is the resolver for the initialSetupWizard field.
|
||||
func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) {
|
||||
db := r.DB(ctx)
|
||||
siteInfo, err := models.GetSiteInfo(db)
|
||||
if err != nil {
|
||||
@@ -159,53 +105,8 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
userPref := models.UserPreferences{
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
var langTrans *models.LanguageTranslation = nil
|
||||
if language != nil {
|
||||
lng := models.LanguageTranslation(*language)
|
||||
langTrans = &lng
|
||||
}
|
||||
|
||||
var userPref models.UserPreferences
|
||||
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userPref.UserID = user.ID
|
||||
userPref.Language = langTrans
|
||||
|
||||
if err := db.Save(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
// Admin queries
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string,
|
||||
admin *bool) (*models.User, error) {
|
||||
// UpdateUser is the resolver for the updateUser field.
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
if username == nil && password == nil && admin == nil {
|
||||
@@ -236,15 +137,14 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "failed to update user")
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string,
|
||||
admin bool) (*models.User, error) {
|
||||
|
||||
// CreateUser is the resolver for the createUser field.
|
||||
func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) {
|
||||
var user *models.User
|
||||
|
||||
transactionError := r.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -264,10 +164,12 @@ func (r *mutationResolver) CreateUser(ctx context.Context, username string, pass
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// DeleteUser is the resolver for the deleteUser field.
|
||||
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||
return actions.DeleteUser(r.DB(ctx), id)
|
||||
}
|
||||
|
||||
// UserAddRootPath is the resolver for the userAddRootPath field.
|
||||
func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
@@ -286,6 +188,7 @@ func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath
|
||||
return newAlbum, nil
|
||||
}
|
||||
|
||||
// UserRemoveRootAlbum is the resolver for the userRemoveRootAlbum field.
|
||||
func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
@@ -339,44 +242,101 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int,
|
||||
return &album, nil
|
||||
}
|
||||
|
||||
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
|
||||
var userAlbumCount int
|
||||
var deletedAlbumIDs []int = nil
|
||||
// ChangeUserPreferences is the resolver for the changeUserPreferences field.
|
||||
func (r *mutationResolver) ChangeUserPreferences(ctx context.Context, language *string) (*models.UserPreferences, error) {
|
||||
db := r.DB(ctx)
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
|
||||
albumID).Scan(&userAlbumCount).Error; err != nil {
|
||||
var langTrans *models.LanguageTranslation = nil
|
||||
if language != nil {
|
||||
lng := models.LanguageTranslation(*language)
|
||||
langTrans = &lng
|
||||
}
|
||||
|
||||
var userPref models.UserPreferences
|
||||
if err := db.Where("user_id = ?", user.ID).FirstOrInit(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userAlbumCount == 0 {
|
||||
deletedAlbumIDs = append(childAlbumIDs, albumID)
|
||||
childAlbumIDs = nil
|
||||
// Delete albums from database
|
||||
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
|
||||
deletedAlbumIDs = nil
|
||||
return nil, err
|
||||
}
|
||||
userPref.UserID = user.ID
|
||||
userPref.Language = langTrans
|
||||
|
||||
if err := db.Save(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deletedAlbumIDs, nil
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
|
||||
if deletedAlbumIDs != nil {
|
||||
// Delete albums from cache
|
||||
for _, id := range deletedAlbumIDs {
|
||||
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
||||
// User is the resolver for the user field.
|
||||
func (r *queryResolver) User(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
|
||||
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Reload faces as media might have been deleted
|
||||
if face_detection.GlobalFaceDetector != nil {
|
||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := models.FormatSQL(r.DB(ctx).Model(models.User{}), order, paginate).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// MyUser is the resolver for the myUser field.
|
||||
func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// MyUserPreferences is the resolver for the myUserPreferences field.
|
||||
func (r *queryResolver) MyUserPreferences(ctx context.Context) (*models.UserPreferences, error) {
|
||||
user := auth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
|
||||
userPref := models.UserPreferences{
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := r.DB(ctx).Where("user_id = ?", user.ID).FirstOrCreate(&userPref).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &userPref, nil
|
||||
}
|
||||
|
||||
// Albums is the resolver for the albums field.
|
||||
func (r *userResolver) Albums(ctx context.Context, obj *models.User) ([]*models.Album, error) {
|
||||
obj.FillAlbums(r.DB(ctx))
|
||||
|
||||
pointerAlbums := make([]*models.Album, len(obj.Albums))
|
||||
for i, album := range obj.Albums {
|
||||
pointerAlbums[i] = &album
|
||||
}
|
||||
|
||||
return pointerAlbums, nil
|
||||
}
|
||||
|
||||
// RootAlbums is the resolver for the rootAlbums field.
|
||||
func (r *userResolver) RootAlbums(ctx context.Context, obj *models.User) (albums []*models.Album, err error) {
|
||||
db := r.DB(ctx)
|
||||
|
||||
err = db.Model(obj).
|
||||
Where("albums.parent_album_id NOT IN (?)",
|
||||
db.Table("user_albums").
|
||||
Select("albums.id").
|
||||
Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", obj.ID),
|
||||
).Or("albums.parent_album_id IS NULL").Order("path ASC").
|
||||
Association("Albums").Find(&albums)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// User returns api.UserResolver implementation.
|
||||
func (r *Resolver) User() api.UserResolver { return &userResolver{r} }
|
||||
|
||||
type userResolver struct{ *Resolver }
|
||||
|
||||
97
api/graphql/resolvers/user.graphql
Normal file
97
api/graphql/resolvers/user.graphql
Normal file
@@ -0,0 +1,97 @@
|
||||
type User {
|
||||
id: ID!
|
||||
username: String!
|
||||
"All albums owned by this user"
|
||||
albums: [Album!]! @isAdmin
|
||||
"Top level albums owned by this user"
|
||||
rootAlbums: [Album!]! @isAdmin
|
||||
"Whether or not the user has admin privileges"
|
||||
admin: Boolean!
|
||||
}
|
||||
|
||||
"Supported language translations of the user interface"
|
||||
enum LanguageTranslation {
|
||||
English,
|
||||
French,
|
||||
Italian,
|
||||
Swedish,
|
||||
Danish,
|
||||
Spanish,
|
||||
Polish,
|
||||
Ukrainian,
|
||||
German,
|
||||
Russian,
|
||||
TraditionalChinese,
|
||||
SimplifiedChinese,
|
||||
Portuguese,
|
||||
Basque,
|
||||
Turkish
|
||||
}
|
||||
|
||||
"Preferences for regular users"
|
||||
type UserPreferences {
|
||||
id: ID!
|
||||
language: LanguageTranslation
|
||||
}
|
||||
|
||||
type AuthorizeResult {
|
||||
success: Boolean!
|
||||
|
||||
"A textual status message describing the result, can be used to show an error message when `success` is false"
|
||||
status: String!
|
||||
"An access token used to authenticate new API requests as the newly authorized user. Is present when success is true"
|
||||
token: String
|
||||
}
|
||||
|
||||
|
||||
extend type Query {
|
||||
"List of registered users, must be admin to call"
|
||||
user(order: Ordering, paginate: Pagination): [User!]! @isAdmin
|
||||
|
||||
"Information about the currently logged in user"
|
||||
myUser: User! @isAuthorized
|
||||
|
||||
"User preferences for the logged in user"
|
||||
myUserPreferences: UserPreferences! @isAuthorized
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
"Authorizes a user and returns a token used to identify the new session"
|
||||
authorizeUser(username: String!, password: String!): AuthorizeResult!
|
||||
|
||||
"Registers the initial user, can only be called if initialSetup from SiteInfo is true"
|
||||
initialSetupWizard(
|
||||
username: String!
|
||||
password: String!
|
||||
rootPath: String!
|
||||
): AuthorizeResult
|
||||
|
||||
"Update a user, fields left as `null` will not be changed"
|
||||
updateUser(
|
||||
id: ID!
|
||||
username: String
|
||||
password: String
|
||||
admin: Boolean
|
||||
): User! @isAdmin
|
||||
"Create a new user"
|
||||
createUser(
|
||||
username: String!
|
||||
password: String
|
||||
admin: Boolean!
|
||||
): User! @isAdmin
|
||||
"Delete an existing user"
|
||||
deleteUser(id: ID!): User! @isAdmin
|
||||
|
||||
"Add a root path from where to look for media for the given user, specified by their user id."
|
||||
userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin
|
||||
|
||||
"""
|
||||
Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
||||
This album was returned when creating the path using `userAddRootPath`.
|
||||
A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
||||
"""
|
||||
userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin
|
||||
|
||||
"Change user preferences for the logged in user"
|
||||
changeUserPreferences(language: String): UserPreferences! @isAuthorized
|
||||
}
|
||||
54
api/graphql/resolvers/user.util.go
Normal file
54
api/graphql/resolvers/user.util.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoview/photoview/api/graphql/models"
|
||||
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||
"github.com/photoview/photoview/api/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func cleanup(tx *gorm.DB, albumID int, childAlbumIDs []int) ([]int, error) {
|
||||
var userAlbumCount int
|
||||
var deletedAlbumIDs []int = nil
|
||||
|
||||
if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?",
|
||||
albumID).Scan(&userAlbumCount).Error; err != nil {
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userAlbumCount == 0 {
|
||||
deletedAlbumIDs = append(childAlbumIDs, albumID)
|
||||
childAlbumIDs = nil
|
||||
// Delete albums from database
|
||||
if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil {
|
||||
deletedAlbumIDs = nil
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return deletedAlbumIDs, nil
|
||||
}
|
||||
|
||||
func clearCacheAndReloadFaces(db *gorm.DB, deletedAlbumIDs []int) error {
|
||||
if deletedAlbumIDs != nil {
|
||||
// Delete albums from cache
|
||||
for _, id := range deletedAlbumIDs {
|
||||
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
||||
|
||||
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Reload faces as media might have been deleted
|
||||
if face_detection.GlobalFaceDetector != nil {
|
||||
if err := face_detection.GlobalFaceDetector.ReloadFacesFromDatabase(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
scripts/test_all.sh
Executable file
13
scripts/test_all.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for test in $(dirname $0)/test_*
|
||||
do
|
||||
if [ "${test}" != "${test%%/scripts/test_all.sh}" ]
|
||||
then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo Running ${test}...
|
||||
${test}
|
||||
done
|
||||
5
scripts/test_api_coverage.sh
Executable file
5
scripts/test_api_coverage.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd $(dirname $0)/../api
|
||||
go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic
|
||||
11
scripts/test_is_generated_code_in_sync.sh
Executable file
11
scripts/test_is_generated_code_in_sync.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd $(dirname $0)/../api
|
||||
go generate ./...
|
||||
if [ "$(git status -s 2>/dev/null | head -1)" != "" ]; then
|
||||
echo '--- FAIL: The generated API code is out of sync with the recent changes. Please run `go generate ./...` under `./api` to regenerate it and commit it to this branch.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '--- PASS: All generated code is in sync with the project.'
|
||||
Reference in New Issue
Block a user