diff --git a/api_coverage_test.go b/api_coverage_test.go new file mode 100644 index 0000000..b40d07e --- /dev/null +++ b/api_coverage_test.go @@ -0,0 +1,191 @@ +package disk + +import ( + "context" + "net/http" + "net/url" + "testing" +) + +// request is what a test server saw. +type request struct { + method string + path string + query url.Values +} + +// recordingClient answers every request with status and body, and records it. +func recordingClient(status int, body string) (*Client, *request) { + var got request + client := mockedHttpClient(func(w http.ResponseWriter, r *http.Request) { + got = request{r.Method, r.URL.Path, r.URL.Query()} + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + }) + return client, &got +} + +func expectQuery(t *testing.T, got *request, method, path string, want map[string]string) { + t.Helper() + if got.method != method || got.path != path { + t.Errorf("request = %s %s, want %s %s", got.method, got.path, method, path) + } + for k, v := range want { + if got.query.Get(k) != v { + t.Errorf("%s = %q, want %q (query %v)", k, got.query.Get(k), v, got.query) + } + } +} + +const linkJSON = `{"href": "https://cloud-api.yandex.net/v1/disk/operations/abc", "method": "GET"}` + +func TestCopyAndMoveSendTheirOptions(t *testing.T) { + ctx := context.Background() + opts := &CopyMoveOptions{Overwrite: true, ForceAsync: true} + want := map[string]string{"from": "disk:/a", "path": "disk:/b", "overwrite": "true", "force_async": "true"} + + client, got := recordingClient(http.StatusAccepted, linkJSON) + if _, errResp := client.CopyResourceWithOptions(ctx, "disk:/a", "disk:/b", opts); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/resources/copy", want) + + client, got = recordingClient(http.StatusAccepted, linkJSON) + if _, errResp := client.MoveResourceWithOptions(ctx, "disk:/a", "disk:/b", opts); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/resources/move", want) + + // Without options nothing extra is sent. + client, got = recordingClient(http.StatusCreated, linkJSON) + if _, errResp := client.CopyResource(ctx, "disk:/a", "disk:/b"); errResp != nil { + t.Fatal(errResp) + } + if got.query.Has("overwrite") || got.query.Has("force_async") { + t.Errorf("plain copy sent options: %v", got.query) + } +} + +func TestDeleteSendsItsOptions(t *testing.T) { + client, got := recordingClient(http.StatusAccepted, linkJSON) + err := client.DeleteResourceWithOptions(context.Background(), "disk:/a.txt", + &DeleteOptions{Permanently: true, ForceAsync: true, MD5: "abc123"}) + if err != nil { + t.Fatal(err) + } + expectQuery(t, got, http.MethodDelete, "/v1/disk/resources", + map[string]string{"path": "disk:/a.txt", "permanently": "true", "force_async": "true", "md5": "abc123"}) +} + +func TestListsSendTheirFilters(t *testing.T) { + ctx := context.Background() + + client, got := recordingClient(http.StatusOK, `{"items": [], "limit": 5}`) + if _, errResp := client.GetLastUploadedResourcesWithOptions(ctx, &PaginationOptions{Limit: 5}, + &FilesOptions{MediaType: []string{"image", "video"}, PreviewSize: "M", Sort: "name"}); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodGet, "/v1/disk/resources/last-uploaded", + map[string]string{"limit": "5", "media_type": "image,video", "preview_size": "M"}) + if got.query.Has("sort") || got.query.Has("offset") { + t.Errorf("last-uploaded takes neither sort nor offset: %v", got.query) + } + + client, got = recordingClient(http.StatusOK, `{"items": [], "limit": 20, "offset": 0}`) + if _, errResp := client.GetPublicResourcesWithOptions(ctx, nil, + &PublicResourcesOptions{Type: "dir", PreviewCrop: true}); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodGet, "/v1/disk/resources/public", + map[string]string{"type": "dir", "preview_crop": "true"}) + + client, got = recordingClient(http.StatusOK, `{"path": "trash:/", "type": "dir", "_embedded": {"items": []}}`) + if _, err := client.ListTrashResourcesWithOptions(ctx, "", &TrashListOptions{Limit: 10, Sort: "-deleted"}); err != nil { + t.Fatal(err) + } + expectQuery(t, got, http.MethodGet, "/v1/disk/trash/resources", map[string]string{"limit": "10", "sort": "-deleted"}) +} + +func TestRestoreSaveAndUploadByURLSendTheirOptions(t *testing.T) { + ctx := context.Background() + + client, got := recordingClient(http.StatusAccepted, linkJSON) + if _, err := client.RestoreFromTrashWithOptions(ctx, "trash:/a.txt", + &RestoreOptions{Overwrite: true, Name: "b.txt", ForceAsync: true}); err != nil { + t.Fatal(err) + } + expectQuery(t, got, http.MethodPut, "/v1/disk/trash/resources/restore", + map[string]string{"overwrite": "true", "name": "b.txt", "force_async": "true"}) + + client, got = recordingClient(http.StatusAccepted, linkJSON) + if _, errResp := client.SavePublicResourceWithOptions(ctx, "key", + &SavePublicResourceOptions{SavePath: "disk:/Inbox", ForceAsync: true}); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/public/resources/save-to-disk", + map[string]string{"public_key": "key", "save_path": "disk:/Inbox", "force_async": "true"}) + + client, got = recordingClient(http.StatusAccepted, linkJSON) + if _, errResp := client.UploadFileWithOptions(ctx, "disk:/f.zip", "https://example.com/f.zip", + &UploadFromURLOptions{DisableRedirects: true}); errResp != nil { + t.Fatal(errResp) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/resources/upload", + map[string]string{"url": "https://example.com/f.zip", "disable_redirects": "true"}) +} + +func TestNewFieldsDecode(t *testing.T) { + client, _ := recordingClient(http.StatusOK, `{ + "total_space": 2199023255552, "used_space": 1000, "paid_max_file_size": 53687091200, + "mail_size": 300, "disk_size": 700, "photounlim_size": 0, "will_be_overdrawn": true, + "monthly_traffic_limit": 1099511627776, "deletion_restriction_days": 90, + "reg_time": "2012-04-04T12:00:00+00:00", + "file_size_limit_upgrades": {"paid": 53687091200, "pro": 107374182400}, + "monthly_traffic_limit_upgrades": {"pro": 2199023255552}, + "system_folders": {"scans": "disk:/Сканы/", "attach": "disk:/Почтовые вложения/", + "messenger": "disk:/Файлы Мессенджера/", "calendar": "disk:/Материалы встреч/"}, + "user": {"login": "demo", "is_child": true, "reg_time": "2012-04-04T12:00:00+00:00"} + }`) + d, err := client.DiskInfo(context.Background()) + if err != nil { + t.Fatal(err) + } + if d.PaidMaxFileSize != 53687091200 || d.MailSize != 300 || d.DiskSize != 700 || !d.WillBeOverdrawn || + d.MonthlyTrafficLimit != 1099511627776 || d.DeletionRestrictionDays != 90 || d.RegTime == "" { + t.Errorf("disk fields: %+v", d) + } + if d.FileSizeLimitUpgrades == nil || d.FileSizeLimitUpgrades.Pro != 107374182400 || + d.MonthlyTrafficLimitUpgrades == nil || d.MonthlyTrafficLimitUpgrades.Pro != 2199023255552 { + t.Errorf("upgrades: %+v %+v", d.FileSizeLimitUpgrades, d.MonthlyTrafficLimitUpgrades) + } + if f := d.SystemFolders; f == nil || f.Scans == "" || f.Attach == "" || f.Messenger == "" || f.Calendar == "" { + t.Errorf("system folders: %+v", f) + } + if !d.User.IsChild || d.User.RegTime == "" { + t.Errorf("user: %+v", d.User) + } + + client, _ = recordingClient(http.StatusOK, `{"name": "cat.jpg", "path": "disk:/cat.jpg", "type": "file", + "exif": {"date_time": "2026-08-16T23:47:00+00:00", "gps_latitude": 64.1466, "gps_longitude": -21.9426}, + "sizes": [{"name": "ORIGINAL", "url": "https://downloader.disk.yandex.ru/x"}, {"name": "S", "url": "https://downloader.disk.yandex.ru/s"}]}`) + r, errResp := client.GetMetadata(context.Background(), "disk:/cat.jpg") + if errResp != nil { + t.Fatal(errResp) + } + if r.Exif == nil || r.Exif.Latitude != 64.1466 || r.Exif.Longitude != -21.9426 { + t.Errorf("exif: %+v", r.Exif) + } + if len(r.Sizes) != 2 || r.Sizes[1].Name != "S" || r.Sizes[1].URL == "" { + t.Errorf("sizes: %+v", r.Sizes) + } +} + +func TestCoordinateToleratesOddShapes(t *testing.T) { + cases := map[string]Coordinate{`64.5`: 64.5, `"-21.25"`: -21.25, `{}`: 0, `null`: 0, `"n/a"`: 0} + for in, want := range cases { + var c Coordinate + if err := c.UnmarshalJSON([]byte(in)); err != nil || c != want { + t.Errorf("%s -> %v, %v; want %v", in, c, err, want) + } + } +} diff --git a/public.go b/public.go index 26706a3..eb11d67 100644 --- a/public.go +++ b/public.go @@ -92,10 +92,16 @@ func (c *Client) GetDownloadURLForPublicResourceAt(ctx context.Context, publicKe } // SavePublicResourceOptions contains optional parameters for saving a published -// resource to the authenticated user's Downloads folder. +// resource to the authenticated user's Disk. type SavePublicResourceOptions struct { Path string // Path to a resource inside a published folder Name string // Name to save the resource under + // SavePath is the folder to save into, such as "disk:/Inbox". Empty + // means the Downloads folder. + SavePath string + // ForceAsync makes the API copy in the background even when the + // resource is small. + ForceAsync bool } // SavePublicResource copies a published file or folder into the user's @@ -124,6 +130,12 @@ func (c *Client) SavePublicResourceWithOptions(ctx context.Context, publicKey st if opts.Name != "" { query.Set("name", opts.Name) } + if opts.SavePath != "" { + query.Set("save_path", opts.SavePath) + } + if opts.ForceAsync { + query.Set("force_async", "true") + } } return requestJSON[Link](ctx, c, POST, "public/resources/save-to-disk?"+query.Encode(), nil, diff --git a/resources.go b/resources.go index 14d385a..8263797 100644 --- a/resources.go +++ b/resources.go @@ -45,20 +45,53 @@ func validatePath(p string) error { } func (c *Client) buildDeleteResourceURL(path string, permanently bool) string { + return c.deleteURL(path, &DeleteOptions{Permanently: permanently}) +} + +func (c *Client) deleteURL(path string, opts *DeleteOptions) string { + if opts == nil { + opts = &DeleteOptions{} + } query := url.Values{} query.Set("path", path) - query.Set("permanently", strconv.FormatBool(permanently)) + query.Set("permanently", strconv.FormatBool(opts.Permanently)) + if opts.ForceAsync { + query.Set("force_async", "true") + } + if opts.MD5 != "" { + query.Set("md5", opts.MD5) + } return fmt.Sprintf("resources?%s", query.Encode()) } +// DeleteOptions are the optional parameters of +// [Client.DeleteResourceWithOptions]. +type DeleteOptions struct { + // Permanently removes the resource for good instead of moving it to the + // trash. + Permanently bool + // ForceAsync makes the API delete in the background even when the + // resource is small. + ForceAsync bool + // MD5, when set, deletes a file only if its contents still have this + // hash, so a file changed in the meantime is kept. Files only. + MD5 string +} + // DeleteResource deletes the file or folder at path. It goes to the trash, // or is removed for good when permanently is true. func (c *Client) DeleteResource(ctx context.Context, path string, permanently bool) error { + return c.DeleteResourceWithOptions(ctx, path, &DeleteOptions{Permanently: permanently}) +} + +// DeleteResourceWithOptions is [Client.DeleteResource] with the choice to +// run in the background or to delete a file only if it is unchanged. +func (c *Client) DeleteResourceWithOptions(ctx context.Context, path string, opts *DeleteOptions) error { if err := validatePath(path); err != nil { return fmt.Errorf("delete error: %w", err) } - url := c.buildDeleteResourceURL(path, permanently) + url := c.deleteURL(path, opts) resp, err := c.doRequest(ctx, DELETE, url, nil) if err != nil { @@ -235,6 +268,35 @@ func (c *Client) CreateDirAll(ctx context.Context, path string) *ErrorResponse { // already exists. Copying a large folder may continue in the background; the // returned [Link] then points to the operation, see [Client.GetOperationStatus]. func (c *Client) CopyResource(ctx context.Context, from, path string) (*Link, *ErrorResponse) { + return c.CopyResourceWithOptions(ctx, from, path, nil) +} + +// CopyMoveOptions are the optional parameters of +// [Client.CopyResourceWithOptions] and [Client.MoveResourceWithOptions]. +type CopyMoveOptions struct { + // Overwrite replaces whatever is at the target path. Without it, an + // existing target makes the request fail with 409. + Overwrite bool + // ForceAsync makes the API run the operation in the background even + // when it is small, and return a link to it. + ForceAsync bool +} + +func (o *CopyMoveOptions) apply(query url.Values) { + if o == nil { + return + } + if o.Overwrite { + query.Set("overwrite", "true") + } + if o.ForceAsync { + query.Set("force_async", "true") + } +} + +// CopyResourceWithOptions is [Client.CopyResource] with the choice to +// overwrite the target or to run in the background. +func (c *Client) CopyResourceWithOptions(ctx context.Context, from, path string, opts *CopyMoveOptions) (*Link, *ErrorResponse) { if len(from) < 1 || len(path) < 1 { return nil, &ErrorResponse{Error: "from and path cannot be empty"} } @@ -242,6 +304,7 @@ func (c *Client) CopyResource(ctx context.Context, from, path string) (*Link, *E query := url.Values{} query.Set("from", from) query.Set("path", path) + opts.apply(query) return requestJSON[Link](ctx, c, POST, "resources/copy?"+query.Encode(), nil, http.StatusOK, http.StatusCreated, http.StatusAccepted) @@ -371,10 +434,21 @@ func (c *Client) GetLastUploadedResources(ctx context.Context) (*LastUploadedRes // GetLastUploadedResourcesWithPagination gets last uploaded resources with pagination support func (c *Client) GetLastUploadedResourcesWithPagination(ctx context.Context, options *PaginationOptions) (*LastUploadedResourceList, *ErrorResponse) { + return c.GetLastUploadedResourcesWithOptions(ctx, options, nil) +} + +// GetLastUploadedResourcesWithOptions is +// [Client.GetLastUploadedResourcesWithPagination] with filters: only some +// media types, and thumbnails of a chosen size. The API does not sort this +// list, so filters.Sort is ignored. +func (c *Client) GetLastUploadedResourcesWithOptions(ctx context.Context, options *PaginationOptions, filters *FilesOptions) (*LastUploadedResourceList, *ErrorResponse) { options = ValidatePaginationOptions(options) query := url.Values{} addPaginationParams(query, options) + filters.apply(query) + query.Del("sort") + query.Del("offset") endpoint := "resources/last-uploaded" if len(query) > 0 { @@ -427,6 +501,12 @@ func (c *Client) GetLastUploadedResourcesIterator(options *PaginationOptions) *P // background; the returned [Link] then points to the operation, see // [Client.GetOperationStatus]. func (c *Client) MoveResource(ctx context.Context, from, path string) (*Link, *ErrorResponse) { + return c.MoveResourceWithOptions(ctx, from, path, nil) +} + +// MoveResourceWithOptions is [Client.MoveResource] with the choice to +// overwrite the target or to run in the background. +func (c *Client) MoveResourceWithOptions(ctx context.Context, from, path string, opts *CopyMoveOptions) (*Link, *ErrorResponse) { if len(from) < 1 || len(path) < 1 { return nil, &ErrorResponse{Error: "from and path cannot be empty"} } @@ -434,6 +514,7 @@ func (c *Client) MoveResource(ctx context.Context, from, path string) (*Link, *E query := url.Values{} query.Set("from", from) query.Set("path", path) + opts.apply(query) return requestJSON[Link](ctx, c, POST, "resources/move?"+query.Encode(), nil, http.StatusCreated, http.StatusAccepted) @@ -447,10 +528,41 @@ func (c *Client) GetPublicResources(ctx context.Context) (*PublicResourcesList, // GetPublicResourcesWithPagination gets public resources with pagination support func (c *Client) GetPublicResourcesWithPagination(ctx context.Context, options *PaginationOptions) (*PublicResourcesList, *ErrorResponse) { + return c.GetPublicResourcesWithOptions(ctx, options, nil) +} + +// PublicResourcesOptions are the filters of +// [Client.GetPublicResourcesWithOptions]. +type PublicResourcesOptions struct { + // Type is "file" or "dir" to list only files or only folders; empty + // lists both. + Type string + PreviewSize string // Thumbnail size, e.g. "M" or "120x240" + PreviewCrop bool // Crop previews to the requested size + Fields []string // Response fields to return (empty = all) +} + +// GetPublicResourcesWithOptions is [Client.GetPublicResourcesWithPagination] +// with filters: only files or only folders, and thumbnails of a chosen size. +func (c *Client) GetPublicResourcesWithOptions(ctx context.Context, options *PaginationOptions, filters *PublicResourcesOptions) (*PublicResourcesList, *ErrorResponse) { options = ValidatePaginationOptions(options) query := url.Values{} addPaginationParams(query, options) + if filters != nil { + if filters.Type != "" { + query.Set("type", filters.Type) + } + if filters.PreviewSize != "" { + query.Set("preview_size", filters.PreviewSize) + } + if filters.PreviewCrop { + query.Set("preview_crop", "true") + } + if len(filters.Fields) > 0 { + query.Set("fields", strings.Join(filters.Fields, ",")) + } + } endpoint := "resources/public" if len(query) > 0 { @@ -543,6 +655,20 @@ func (c *Client) GetLinkForUpload(ctx context.Context, path string) (*ResourceUp // in the background; the returned [Link] points to the operation, see // [Client.GetOperationStatus]. func (c *Client) UploadFile(ctx context.Context, path, uploadURL string) (*Link, *ErrorResponse) { + return c.UploadFileWithOptions(ctx, path, uploadURL, nil) +} + +// UploadFromURLOptions are the optional parameters of +// [Client.UploadFileWithOptions]. +type UploadFromURLOptions struct { + // DisableRedirects makes Yandex refuse to follow redirects from + // uploadURL. + DisableRedirects bool +} + +// UploadFileWithOptions is [Client.UploadFile] with the choice not to follow +// redirects. +func (c *Client) UploadFileWithOptions(ctx context.Context, path, uploadURL string, opts *UploadFromURLOptions) (*Link, *ErrorResponse) { if len(path) < 1 || len(uploadURL) < 1 { return nil, &ErrorResponse{Error: "path and url cannot be empty"} } @@ -550,6 +676,9 @@ func (c *Client) UploadFile(ctx context.Context, path, uploadURL string) (*Link, queryParams := url.Values{} queryParams.Set("path", path) queryParams.Set("url", uploadURL) + if opts != nil && opts.DisableRedirects { + queryParams.Set("disable_redirects", "true") + } return requestJSON[Link](ctx, c, POST, "resources/upload?"+queryParams.Encode(), nil, http.StatusOK, http.StatusAccepted) diff --git a/trash.go b/trash.go index b3609f8..bc49d61 100644 --- a/trash.go +++ b/trash.go @@ -6,21 +6,46 @@ import ( "net/http" "net/url" "strconv" + "strings" ) // RestoreFromTrash restores a resource from trash to its original location or a new path func (c *Client) RestoreFromTrash(ctx context.Context, path string, overwrite bool, name string) (*Link, error) { + return c.RestoreFromTrashWithOptions(ctx, path, &RestoreOptions{Overwrite: overwrite, Name: name}) +} + +// RestoreOptions are the optional parameters of +// [Client.RestoreFromTrashWithOptions]. +type RestoreOptions struct { + // Overwrite replaces whatever is now at the original path. + Overwrite bool + // Name restores the resource under another name. + Name string + // ForceAsync makes the API restore in the background even when the + // resource is small. + ForceAsync bool +} + +// RestoreFromTrashWithOptions is [Client.RestoreFromTrash] with the choice to +// run in the background. +func (c *Client) RestoreFromTrashWithOptions(ctx context.Context, path string, opts *RestoreOptions) (*Link, error) { if path == "" { return nil, fmt.Errorf("path cannot be empty") } + if opts == nil { + opts = &RestoreOptions{} + } query := url.Values{} query.Set("path", path) - if overwrite { + if opts.Overwrite { query.Set("overwrite", "true") } - if name != "" { - query.Set("name", name) + if opts.Name != "" { + query.Set("name", opts.Name) + } + if opts.ForceAsync { + query.Set("force_async", "true") } c.Logger.Debug("Restoring resource from trash: %s", path) @@ -52,15 +77,47 @@ func (c *Client) RestoreFromTrash(ctx context.Context, path string, overwrite bo // ListTrashResources lists resources in the trash, optionally filtered by path func (c *Client) ListTrashResources(ctx context.Context, path string, limit int, offset int) (*TrashResourceList, error) { + return c.ListTrashResourcesWithOptions(ctx, path, &TrashListOptions{Limit: limit, Offset: offset}) +} + +// TrashListOptions are the optional parameters of +// [Client.ListTrashResourcesWithOptions]. +type TrashListOptions struct { + Limit int // Page size (0 = API default) + Offset int // Items to skip + Sort string // "deleted" or "created" (prefix "-" to reverse) + PreviewSize string // Thumbnail size, e.g. "M" or "120x240" + PreviewCrop bool // Crop previews to the requested size + Fields []string // Response fields to return (empty = all) +} + +// ListTrashResourcesWithOptions is [Client.ListTrashResources] with sorting +// and thumbnails. +func (c *Client) ListTrashResourcesWithOptions(ctx context.Context, path string, opts *TrashListOptions) (*TrashResourceList, error) { + if opts == nil { + opts = &TrashListOptions{} + } query := url.Values{} if path != "" { query.Set("path", path) } - if limit > 0 { - query.Set("limit", strconv.Itoa(limit)) + if opts.Limit > 0 { + query.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Offset > 0 { + query.Set("offset", strconv.Itoa(opts.Offset)) + } + if opts.Sort != "" { + query.Set("sort", opts.Sort) + } + if opts.PreviewSize != "" { + query.Set("preview_size", opts.PreviewSize) + } + if opts.PreviewCrop { + query.Set("preview_crop", "true") } - if offset > 0 { - query.Set("offset", strconv.Itoa(offset)) + if len(opts.Fields) > 0 { + query.Set("fields", strings.Join(opts.Fields, ",")) } c.Logger.Debug("Listing trash resources with path: %s", path) diff --git a/types.go b/types.go index 81b6766..3a2bd59 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,10 @@ package disk +import ( + "encoding/json" + "strconv" +) + // Disk describes the user's Disk as a whole: space, limits and the owner. // [Client.DiskInfo] returns it. // @@ -10,14 +15,44 @@ type Disk struct { UnlimitedAutouploadEnabled bool `json:"unlimited_autoupload_enabled,omitempty"` // MaxFileSize is the largest file that can be uploaded. MaxFileSize int64 `json:"max_file_size,omitempty"` + // PaidMaxFileSize is the largest file a paid plan can upload. + PaidMaxFileSize int64 `json:"paid_max_file_size,omitempty"` + // FileSizeLimitUpgrades lists the larger file limits paid plans offer. + FileSizeLimitUpgrades *FileSizeLimit `json:"file_size_limit_upgrades,omitempty"` // TotalSpace is the size of the Disk. TotalSpace int64 `json:"total_space,omitempty"` // TrashSize is how much the trash takes up. It counts towards UsedSpace. TrashSize int64 `json:"trash_size,omitempty"` // IsPaid reports whether the user has a paid plan. IsPaid bool `json:"is_paid,omitempty"` - // UsedSpace is how much of TotalSpace is taken, trash included. + // UsedSpace is how much of TotalSpace is taken, trash and mail included. UsedSpace int64 `json:"used_space,omitempty"` + // DiskSize is how much the files on the Disk take up. + DiskSize int64 `json:"disk_size,omitempty"` + // MailSize is how much Yandex Mail takes up. It counts towards UsedSpace. + MailSize int64 `json:"mail_size,omitempty"` + // PhotounlimSize is how much the unlimited photo upload from phones + // takes up. + PhotounlimSize int64 `json:"photounlim_size,omitempty"` + // FreePhotounlimEndDate is when the free unlimited photo upload ends, as + // a Unix time in milliseconds, or 0. + FreePhotounlimEndDate int64 `json:"free_photounlim_end_date,omitempty"` + // WillBeOverdrawn reports whether the user will be over the limit once + // the free unlimited photo upload ends. + WillBeOverdrawn bool `json:"will_be_overdrawn,omitempty"` + // MonthlyTrafficLimit is how much can be uploaded in a month, or 0 when + // there is no limit. + MonthlyTrafficLimit int64 `json:"monthly_traffic_limit,omitempty"` + // MonthlyTrafficLimitUpgrades lists the larger monthly limits paid plans + // offer. + MonthlyTrafficLimitUpgrades *UploadTrafficLimit `json:"monthly_traffic_limit_upgrades,omitempty"` + // DeletionRestrictionDays is how many days files are kept after the + // account is blocked. + DeletionRestrictionDays int `json:"deletion_restriction_days,omitempty"` + // IsLegalEntity reports whether the account belongs to a company. + IsLegalEntity bool `json:"is_legal_entity,omitempty"` + // RegTime is when the Disk was created, as an ISO 8601 string. + RegTime string `json:"reg_time,omitempty"` // SystemFolders holds the paths of folders the Disk manages itself. SystemFolders *SystemFolders `json:"system_folders,omitempty"` // User is the owner of the Disk. @@ -42,6 +77,25 @@ type SystemFolders struct { Social string `json:"social,omitempty"` Screenshots string `json:"screenshots,omitempty"` Photostream string `json:"photostream,omitempty"` + Scans string `json:"scans,omitempty"` + Attach string `json:"attach,omitempty"` // mail attachments + Messenger string `json:"messenger,omitempty"` // files from Yandex Messenger + Calendar string `json:"calendar,omitempty"` // meeting materials +} + +// FileSizeLimit lists the largest file each paid plan can upload, in bytes. +type FileSizeLimit struct { + // Paid is the limit on a paid plan. + Paid int64 `json:"paid,omitempty"` + // Pro is the limit on the Pro plan. + Pro int64 `json:"pro,omitempty"` +} + +// UploadTrafficLimit lists the monthly upload limit each paid plan offers, +// in bytes. +type UploadTrafficLimit struct { + // Pro is the limit on the Pro plan. + Pro int64 `json:"pro,omitempty"` } // User is the owner of a Disk, as part of [Disk]. @@ -54,6 +108,10 @@ type User struct { DisplayName string `json:"display_name,omitempty"` // Uid is the numeric user ID, as a string. Uid string `json:"uid,omitempty"` + // IsChild reports whether this is a child account. + IsChild bool `json:"is_child,omitempty"` + // RegTime is when the Disk was created, as an ISO 8601 string. + RegTime string `json:"reg_time,omitempty"` } // Resource is a file or a folder on the Disk. @@ -87,6 +145,8 @@ type Resource struct { MediaType string `json:"media_type,omitempty"` // Preview is a link to a thumbnail. Fetching it needs the access token. Preview string `json:"preview,omitempty"` + // Sizes lists the thumbnail in every available size. + Sizes []PreviewSize `json:"sizes,omitempty"` // Type is "file" or "dir". Type string `json:"type"` // MimeType is the MIME type of a file, such as "image/jpeg". @@ -157,6 +217,41 @@ type ResourceList struct { type Exif struct { // DateTime is when the photo was taken, as an ISO 8601 string. DateTime string `json:"date_time,omitempty"` + // Latitude and Longitude are where the photo was taken, if the camera + // recorded it, and 0 otherwise. + Latitude Coordinate `json:"gps_latitude,omitempty"` + Longitude Coordinate `json:"gps_longitude,omitempty"` +} + +// Coordinate is a GPS coordinate in degrees. The API schema does not fix its +// type, so a number or a numeric string is read, and anything else, such as +// an empty object, counts as no coordinate instead of failing the request. +type Coordinate float64 + +// UnmarshalJSON implements [encoding/json.Unmarshaler]. +func (c *Coordinate) UnmarshalJSON(data []byte) error { + var f float64 + if err := json.Unmarshal(data, &f); err == nil { + *c = Coordinate(f) + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + if f, err := strconv.ParseFloat(s, 64); err == nil { + *c = Coordinate(f) + return nil + } + } + *c = 0 + return nil +} + +// PreviewSize is one size of a resource's thumbnail. +type PreviewSize struct { + // Name is the size, such as "S", "XL" or "ORIGINAL". + Name string `json:"name"` + // URL is where to download the thumbnail. It needs the access token. + URL string `json:"url"` } // CommentIds identify the comment threads attached to a resource.