From 716836d1fc65aebc3d8706303e499c18ed29ccc9 Mon Sep 17 00:00:00 2001 From: Ilya Brin <464157+ilyabrin@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:19:46 +0300 Subject: [PATCH] feat: cover the remaining API parameters and fields New *WithOptions variants for copy, move, delete, restore, the trash list, last uploaded and public resources, and upload by URL, adding overwrite, force_async, md5, filters, sorting and previews. The existing methods call them and do not change. Disk, User, Resource and Exif gain the fields the API returns, including those lost in v1.1.0. GPS coordinates use a Coordinate type that tolerates the empty object the API schema allows, so an odd value cannot fail a whole listing. --- api_coverage_test.go | 191 +++++++++++++++++++++++++++++++++++++++++++ public.go | 14 +++- resources.go | 133 +++++++++++++++++++++++++++++- trash.go | 71 ++++++++++++++-- types.go | 97 +++++++++++++++++++++- 5 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 api_coverage_test.go 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.