[
  {
    "path": ".github/workflows/dockerhub.yml",
    "content": "name: Publish Docker image\non:\n  push:\n    tags:\n      - \"v*\"\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout source\n        uses: actions/checkout@v2\n\n      - name: Docker meta\n        id: docker_meta\n        uses: docker/metadata-action@v5\n        with:\n          images: 52funny/pikpakcli\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Push Docker Hub\n        uses: docker/build-push-action@v6\n        with:\n          push: true\n          context: .\n          platforms: linux/amd64,linux/arm64\n          file: ./Dockerfile\n          tags: ${{ steps.docker_meta.outputs.tags }}"
  },
  {
    "path": ".github/workflows/goreleaser.yml",
    "content": "name: goreleaser\n\non:\n  push:\n    tags:\n      - \"v*\"\n\npermissions:\n  contents: write\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Set up Go\n        uses: actions/setup-go@v5\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "\n.vscode\n.pikpaksync.txt\nconfig.yml\npikpakcli\ndist\ndist/\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "# This is an example .goreleaser.yml file with some sensible defaults.\n# Make sure to check the documentation at https://goreleaser.com\n\n# The lines below are called `modelines`. See `:help modeline`\n# Feel free to remove those if you don't want/need to use them.\n# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n# vim: set ts=2 sw=2 tw=0 fo=cnqoj\n\nversion: 2\n\nbefore:\n  hooks:\n    # You may remove this if you don't use go modules.\n    - go mod tidy\n    # you may remove this if you don't need go generate\n    - go generate ./...\n\nbuilds:\n  - env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - windows\n      - darwin\n\narchives:\n  - format: tar.gz\n    # this name template makes the OS and Arch compatible with the results of `uname`.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- title .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    # use zip for windows archives\n    format_overrides:\n      - goos: windows\n        format: zip\n    files:\n      - config_example.yml\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.21-alpine AS builder\n\nRUN apk add --no-cache git\nWORKDIR /src\n\nCOPY go.mod go.sum ./\nRUN go mod download\n\nCOPY . .\nRUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \\\n\tgo build -ldflags \"-s -w\" -o /usr/local/bin/pikpakcli ./main.go\n\nFROM alpine:3.18\nRUN apk add --no-cache ca-certificates\nCOPY --from=builder /usr/local/bin/pikpakcli /usr/local/bin/pikpakcli\nWORKDIR /root\n\nENTRYPOINT [\"/usr/local/bin/pikpakcli\"]\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 52funny\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# PikPak CLI\n\n![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/52funny/pikpakcli)\n![GitHub](https://img.shields.io/github/license/52funny/pikpakcli)\n\nEnglish | [简体中文](https://github.com/52funny/pikpakcli/blob/master/README_zhCN.md)\n\nPikPakCli is a command line tool for Pikpak Cloud.\n\n![Build from source code.](./images/build.gif)\n\n## Installation\n\n### Compiling from source code\n\nTo build the tool from the source code, ensure you have [Go](https://go.dev/doc/install) installed on your system.\n\nClone the project:\n\n```bash\ngit clone https://github.com/52funny/pikpakcli\n```\n\nBuild the project:\n\n```bash\ngo build\n```\n\nRun the tool:\n\n```\n./pikpakcli\n```\n\n### Build with Docker\nYou can also run `pikpakcli` using Docker.\nPull the Docker image:\n\n```bash\ndocker pull 52funny/pikpakcli:master\n```\n\nRun the tool:\n```bash\ndocker run --rm 52funny/pikpakcli:master --help\n```\n\n### Download from Release\n\nDownload the executable file you need from the [Releases](https://github.com/52funny/pikpakcli/releases) page, then run it.\n\n## Configuration\n\nFirst, configure the `config_example.yml` file in the project, entering your account details.\n\nIf your account uses a phone number, it must be preceded by the country code, like `+861xxxxxxxxxx`.\n\nThen, rename it to `config.yml`.\n\nThe configuration file will first be read from the current directory (`config.yml`). If it doesn't exist there, it will be read from the user's default configuration directory. The default root directories for each platform are:\n\n- Linux: `$HOME/.config/pikpakcli`\n- Darwin: `$HOME/Library/Application Support/pikpakcli`\n- Windows: `%AppData%/pikpakcli`\n\nThe optional `open` section can override which local program is used by the interactive shell `open` builtin for different file categories.\n\n> **For Docker Users:** You need to mount the configuration file into the Docker container. For example, if your `config.yml` is located at `/path/to/your/config.yml`, you can run the Docker container like this:\n\n```bash\ndocker run -v /path/to/your/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls\n# if your config.yml is in the project directory, you can just run:\ndocker run -v $PWD/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls\n```\n\n## Get started\n\nAfter that you can run the `ls` command to see the files stored on **PikPak**.\n\n```bash\n./pikpakcli ls\n```\n\n## Usage\n\nSee [Command](docs/command.md) for more commands information.\n\n## Contributors\n\n<a href = \"https://github.com/52funny/pikpakcli/graphs/contributors\">\n  <img src = \"https://contrib.rocks/image?repo=52funny/pikpakcli\"/>\n</a>\n"
  },
  {
    "path": "README_zhCN.md",
    "content": "# PikPak CLI\n\n![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/52funny/pikpakcli)\n![GitHub](https://img.shields.io/github/license/52funny/pikpakcli)\n\nPikPakCli 是 PikPak 的命令行工具。\n\n![Build from source code.](./images/build.gif)\n\n## 安装方法\n\n### 从源码编译\n\n要从源代码构建该工具，请确保您的系统已安装 [Go](https://go.dev/doc/install) 环境。\n\n克隆项目\n\n```bash\ngit clone https://github.com/52funny/pikpakcli\n```\n\n生成可执行文件\n\n```bash\ngo build\n```\n\n运行\n\n```bash\n./pikpakcli\n```\n\n### 从 Release 下载\n\n从 Release 下载你所需要的版本，然后运行。\n\n## 配置文件\n\n首先将项目中的 `config_example.yml` 配置一下，输入自己的账号密码\n\n如果账号是手机号，手机号要以区号开头。如 `+861xxxxxxxxxx`\n\n然后将其重命名为 `config.yml`\n\n配置文件将会优先从当前目录进行读取 `config.yml`，如果当前目录下不存在 `config.yml` 将会从用户的配置数据的默认根目录进行读取，各个平台的默认根目录如下：\n\n- Linux: `$HOME/.config/pikpakcli`\n- Darwin: `$HOME/Library/Application Support/pikpakcli`\n- Windows: `%AppData%/pikpakcli`\n\n可选的 `open` 配置段可以覆盖交互式 shell 中 `open` 内置命令针对不同文件类型使用的本地程序。\n\n## 开始\n\n之后你就可以运行 `ls` 指令来查看存储在 **PikPak** 上的文件了\n\n```bash\n./pikpakcli ls\n```\n\n## 用法\n\n参阅 [Command](docs/command_zhCN.md) 查看更多的指令\n\n## 贡献者\n\n<a href = \"https://github.com/52funny/pikpakcli/graphs/contributors\">\n  <img src = \"https://contrib.rocks/image?repo=52funny/pikpakcli\"/>\n</a>\n"
  },
  {
    "path": "cli/del/del.go",
    "content": "package delete\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar path string\n\nvar DeleteCmd = &cobra.Command{\n\tUse:     \"delete [file-or-folder ...]\",\n\tAliases: []string{\"del\", \"rm\"},\n\tShort:   \"Delete files or folders on the PikPak server\",\n\tArgs:    cobra.MinimumNArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\tif err := p.Login(); err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\n\t\tflagPathSpecified := cmd.Flags().Changed(\"path\")\n\t\targs, err := api.ExpandRemotePatterns(&p, path, args, flagPathSpecified)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Expand delete target failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tfor parentPath, names := range groupDeleteTargets(args, flagPathSpecified) {\n\t\t\tif err := deleteEntries(&p, parentPath, names); err != nil {\n\t\t\t\tfmt.Println(\"Delete entries failed\")\n\t\t\t\tlogx.Error(err)\n\t\t\t}\n\t\t}\n\t},\n}\n\nfunc init() {\n\tDeleteCmd.Flags().StringVarP(&path, \"path\", \"p\", \"/\", \"The path where to look for the file\")\n}\n\nfunc groupDeleteTargets(args []string, forceParentPath bool) map[string][]string {\n\ttargets := make(map[string][]string)\n\tfor _, arg := range args {\n\t\tparentPath := path\n\t\tname := arg\n\n\t\tif !forceParentPath || strings.HasPrefix(arg, \"/\") || strings.Contains(arg, \"/\") {\n\t\t\tresolvedParentPath, resolvedName := utils.SplitRemotePath(arg)\n\t\t\tif resolvedName == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tname = resolvedName\n\t\t\tif resolvedParentPath == \"\" {\n\t\t\t\tparentPath = \"/\"\n\t\t\t} else {\n\t\t\t\tparentPath = resolvedParentPath\n\t\t\t}\n\t\t}\n\n\t\ttargets[parentPath] = append(targets[parentPath], name)\n\t}\n\treturn targets\n}\n\nfunc deleteEntries(p *api.PikPak, parentPath string, names []string) error {\n\tparentID, err := p.GetPathFolderId(parentPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"get path folder id for %s failed: %w\", parentPath, err)\n\t}\n\n\tfiles, err := p.GetFolderFileStatList(parentID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"get file list for %s failed: %w\", parentPath, err)\n\t}\n\n\tfileIndex := make(map[string]api.FileStat, len(files))\n\tfor _, file := range files {\n\t\tfileIndex[file.Name] = file\n\t}\n\n\tfor _, name := range names {\n\t\tfile, ok := fileIndex[name]\n\t\tif !ok {\n\t\t\tfmt.Printf(\"Entry not found in %s: %s\\n\", parentPath, name)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := p.DeleteFile(file.ID); err != nil {\n\t\t\tfmt.Printf(\"Delete %s from %s failed\\n\", name, parentPath)\n\t\t\tlogx.Error(err)\n\t\t\tcontinue\n\t\t}\n\n\t\tfmt.Printf(\"Deleted %s from %s\\n\", name, parentPath)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/download/download.go",
    "content": "package download\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/vbauerster/mpb/v8\"\n\t\"github.com/vbauerster/mpb/v8/decor\"\n)\n\nvar DownloadCmd = &cobra.Command{\n\tUse:     \"download\",\n\tAliases: []string{\"d\"},\n\tShort:   `Download file from pikpak server`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tif len(args) == 0 {\n\t\t\tcmd.Help()\n\t\t\treturn\n\t\t}\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\targs, err = api.ExpandRemotePatterns(&p, folder, args, false)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Expand download target failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\thandleDownload(cmd, &p, args)\n\t},\n}\n\n// Number of simultaneous downloads\n//\n// default 1\nvar count int\n\n// Specifies the folder of the pikpak server\n//\n// default server root directory (.)\nvar folder string\n\n// parent path id\nvar parentId string\n\n// Output directory\n//\n// default current directory (.)\nvar output string\n\n// Progress bar\n//\n// default false\nvar progress bool\n\ntype warpFile struct {\n\tf      *api.File\n\toutput string\n}\n\ntype warpStat struct {\n\ts      api.FileStat\n\toutput string\n}\n\nconst progressNameMaxRunes = 36\n\nfunc init() {\n\tDownloadCmd.Flags().IntVarP(&count, \"count\", \"c\", 1, \"number of simultaneous downloads\")\n\tDownloadCmd.Flags().StringVarP(&output, \"output\", \"o\", \".\", \"output directory\")\n\tDownloadCmd.Flags().StringVarP(&folder, \"path\", \"p\", \"/\", \"specific the base path on the pikpak server\")\n\tDownloadCmd.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"the parent path id\")\n\tDownloadCmd.Flags().BoolVarP(&progress, \"progress\", \"g\", false, \"show download progress\")\n}\n\ntype downloadTargetResolver interface {\n\tGetFileByPath(path string) (api.FileStat, error)\n\tGetFileStat(parentId string, name string) (api.FileStat, error)\n\tGetPathFolderId(dirPath string) (string, error)\n}\n\nfunc handleDownload(cmd *cobra.Command, p *api.PikPak, args []string) {\n\tif err := utils.CreateDirIfNotExist(output); err != nil {\n\t\tfmt.Println(\"Create output directory failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\n\tif requiresExplicitOutputFlag(cmd, args) {\n\t\tfmt.Println(\"Use -o to specify the output directory when downloading specific files\")\n\t\treturn\n\t}\n\n\tfor _, arg := range args {\n\t\tdownloadTarget(p, arg)\n\t}\n}\n\nfunc requiresExplicitOutputFlag(cmd *cobra.Command, args []string) bool {\n\tif cmd.Flags().Changed(\"output\") || len(args) <= 1 {\n\t\treturn false\n\t}\n\tfor _, arg := range args {\n\t\ttrimmed := strings.TrimSpace(arg)\n\t\tif trimmed == \".\" || trimmed == \"..\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc downloadTarget(p *api.PikPak, arg string) {\n\tstat, err := resolveDownloadTarget(p, arg)\n\tif err != nil {\n\t\ttarget := remoteTargetPath(arg)\n\t\tfmt.Println(\"Resolve download target failed:\", target)\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\n\tif stat.Kind == api.FileKindFolder {\n\t\tdownloadFolder(p, stat.ID, localOutputRoot(stat.Name))\n\t\treturn\n\t}\n\n\tdownloadFiles(p, []warpFile{\n\t\t{\n\t\t\tf:      mustGetFile(p, stat),\n\t\t\toutput: output,\n\t\t},\n\t})\n}\n\nfunc downloadFolder(p *api.PikPak, folderID string, rootOutput string) {\n\tcollectStat := make([]warpStat, 0)\n\trecursive(p, &collectStat, folderID, rootOutput)\n\tdownloadStats(p, collectStat)\n}\n\nfunc downloadStats(p *api.PikPak, collectStat []warpStat) {\n\tif len(collectStat) == 0 {\n\t\treturn\n\t}\n\n\tstatCh := make(chan warpStat, len(collectStat))\n\tstatDone := make(chan struct{})\n\n\tfileCh := make(chan warpFile, len(collectStat))\n\tfileDone := make(chan struct{})\n\n\tfor i := 0; i < 4; i += 1 {\n\t\tgo func(fileCh chan<- warpFile, statCh <-chan warpStat, statDone chan<- struct{}) {\n\t\t\tfor {\n\t\t\t\tstat, ok := <-statCh\n\t\t\t\tif !ok {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tfile, err := p.GetFile(stat.s.ID)\n\t\t\t\tif err != nil {\n\t\t\t\t\tfmt.Println(\"Get file failed\")\n\t\t\t\t\tlogx.Error(err)\n\t\t\t\t}\n\t\t\t\tfileCh <- warpFile{\n\t\t\t\t\tf:      &file,\n\t\t\t\t\toutput: stat.output,\n\t\t\t\t}\n\t\t\t\tstatDone <- struct{}{}\n\t\t\t}\n\t\t}(fileCh, statCh, statDone)\n\t}\n\n\tpb := startDownloadWorkers(fileCh, fileDone)\n\n\tfor i := 0; i < len(collectStat); i += 1 {\n\t\terr := utils.CreateDirIfNotExist(collectStat[i].output)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Create output directory failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tstatCh <- collectStat[i]\n\t}\n\tclose(statCh)\n\n\tfor i := 0; i < len(collectStat); i += 1 {\n\t\t<-statDone\n\t}\n\tclose(statDone)\n\n\tfor i := 0; i < len(collectStat); i += 1 {\n\t\t<-fileDone\n\t}\n\tif pb != nil {\n\t\tpb.Wait()\n\t}\n}\n\nfunc recursive(p *api.PikPak, collectWarpFile *[]warpStat, parentId string, parentPath string) {\n\tstatList, err := p.GetFolderFileStatList(parentId)\n\tif err != nil {\n\t\tfmt.Println(\"Get folder file stat list failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tfor _, r := range statList {\n\t\tif r.Kind == api.FileKindFolder {\n\t\t\trecursive(p, collectWarpFile, r.ID, filepath.Join(parentPath, r.Name))\n\t\t} else {\n\t\t\t// file, _ := p.GetFile(r.ID)\n\t\t\t*collectWarpFile = append(*collectWarpFile, warpStat{\n\t\t\t\ts:      r,\n\t\t\t\toutput: parentPath,\n\t\t\t})\n\t\t\t// fmt.Println(r.Name, r.Size, r.Kind, parentPath)\n\t\t}\n\t}\n}\n\nfunc downloadFiles(p *api.PikPak, files []warpFile) {\n\tsendCh := make(chan warpFile, len(files))\n\treceiveCh := make(chan struct{}, len(files))\n\tpb := startDownloadWorkers(sendCh, receiveCh)\n\tfor _, file := range files {\n\t\tsendCh <- file\n\t}\n\tclose(sendCh)\n\tfor i := 0; i < len(files); i++ {\n\t\t<-receiveCh\n\t}\n\tclose(receiveCh)\n\tif pb != nil {\n\t\tpb.Wait()\n\t}\n}\n\nfunc startDownloadWorkers(sendCh <-chan warpFile, receiveCh chan<- struct{}) *mpb.Progress {\n\tvar pb *mpb.Progress\n\tif progress {\n\t\tpb = mpb.New(\n\t\t\tmpb.WithWidth(30),\n\t\t\tmpb.WithAutoRefresh(),\n\t\t)\n\t}\n\n\tfor i := 0; i < count; i++ {\n\t\tgo download(sendCh, receiveCh, pb)\n\t}\n\n\treturn pb\n}\n\nfunc resolveDownloadTarget(p downloadTargetResolver, arg string) (api.FileStat, error) {\n\tif target := strings.TrimSpace(arg); target == \"\" {\n\t\tif parentId != \"\" {\n\t\t\treturn api.FileStat{\n\t\t\t\tKind: api.FileKindFolder,\n\t\t\t\tID:   parentId,\n\t\t\t\tName: filepath.Base(filepath.Clean(folder)),\n\t\t\t}, nil\n\t\t}\n\t\tremotePath := remoteTargetPath(\"\")\n\t\tif remotePath == string(filepath.Separator) {\n\t\t\tid, err := p.GetPathFolderId(folder)\n\t\t\tif err != nil {\n\t\t\t\treturn api.FileStat{}, err\n\t\t\t}\n\t\t\treturn api.FileStat{\n\t\t\t\tKind: api.FileKindFolder,\n\t\t\t\tID:   id,\n\t\t\t\tName: \"\",\n\t\t\t}, nil\n\t\t}\n\t\treturn p.GetFileByPath(remotePath)\n\t}\n\n\tif parentId != \"\" && !filepath.IsAbs(arg) && !strings.Contains(arg, string(filepath.Separator)) {\n\t\treturn p.GetFileStat(parentId, arg)\n\t}\n\n\treturn p.GetFileByPath(remoteTargetPath(arg))\n}\n\nfunc remoteTargetPath(arg string) string {\n\tbase := strings.TrimSpace(folder)\n\ttarget := strings.TrimSpace(arg)\n\tif target == \"\" {\n\t\ttarget = \".\"\n\t}\n\tif filepath.IsAbs(target) {\n\t\treturn filepath.Clean(target)\n\t}\n\treturn filepath.Clean(filepath.Join(string(filepath.Separator), base, target))\n}\n\nfunc localOutputRoot(name string) string {\n\tif strings.TrimSpace(name) == \"\" || name == string(filepath.Separator) || name == \".\" {\n\t\treturn output\n\t}\n\treturn filepath.Join(output, name)\n}\n\nfunc mustGetFile(p *api.PikPak, stat api.FileStat) *api.File {\n\tfile, err := p.GetFile(stat.ID)\n\tif err != nil {\n\t\tfmt.Println(\"Get file failed\")\n\t\tlogx.Error(err)\n\t\treturn &api.File{FileStat: stat}\n\t}\n\treturn &file\n}\n\nfunc progressDisplayName(warp warpFile) string {\n\tname := warp.f.Name\n\tif base := filepath.Base(filepath.Clean(warp.output)); base != \".\" && base != string(filepath.Separator) && base != \"\" {\n\t\tname = filepath.Join(base, name)\n\t}\n\treturn trimRunes(name, progressNameMaxRunes)\n}\n\nfunc trimRunes(value string, max int) string {\n\trunes := []rune(value)\n\tif len(runes) <= max {\n\t\treturn value\n\t}\n\tif max <= 3 {\n\t\treturn string(runes[:max])\n\t}\n\treturn string(runes[:max-3]) + \"...\"\n}\n\nfunc download(inCh <-chan warpFile, out chan<- struct{}, pb *mpb.Progress) {\n\tfor {\n\t\twarp, ok := <-inCh\n\t\tif !ok {\n\t\t\tbreak\n\t\t}\n\n\t\tpath := filepath.Join(warp.output, warp.f.Name)\n\t\texist, err := utils.Exists(path)\n\t\tif err != nil {\n\t\t\t// logrus.Errorln(\"Access\", path, \"Failed:\", err)\n\t\t\tout <- struct{}{}\n\t\t\tcontinue\n\t\t}\n\t\tflag := path + \".pikpakclidownload\"\n\t\thasFlag, err := utils.Exists(flag)\n\t\tif err != nil {\n\t\t\t// logrus.Errorln(\"Access\", flag, \"Failed:\", err)\n\t\t\tout <- struct{}{}\n\t\t\tcontinue\n\t\t}\n\t\tif exist && !hasFlag {\n\t\t\t// logrus.Infoln(\"Skip downloaded file\", warp.f.Name)\n\t\t\tout <- struct{}{}\n\t\t\tcontinue\n\t\t}\n\t\terr = utils.TouchFile(flag)\n\t\tif err != nil {\n\t\t\t// logrus.Errorln(\"Create flag file\", flag, \"Failed:\", err)\n\t\t\tout <- struct{}{}\n\t\t\tcontinue\n\t\t}\n\n\t\tsiz, err := strconv.ParseInt(warp.f.Size, 10, 64)\n\t\tif err != nil {\n\t\t\t// logrus.Errorln(\"Parse File size\", warp.f.Size, \"Failed:\", err)\n\t\t\tout <- struct{}{}\n\t\t\tcontinue\n\t\t}\n\n\t\tvar bar *mpb.Bar\n\n\t\tif pb != nil {\n\t\t\tbar = pb.AddBar(siz,\n\t\t\t\tmpb.PrependDecorators(\n\t\t\t\t\tdecor.Name(progressDisplayName(warp), decor.WC{W: progressNameMaxRunes + 2, C: decor.DSyncWidth}),\n\t\t\t\t\tdecor.CountersKibiByte(\"% .1f / % .1f\", decor.WCSyncSpace),\n\t\t\t\t\tdecor.Percentage(decor.WCSyncSpace),\n\t\t\t\t),\n\t\t\t\tmpb.AppendDecorators(\n\t\t\t\t\tdecor.Name(\" | \", decor.WCSyncSpace),\n\t\t\t\t\tdecor.Name(\"ETA \", decor.WCSyncSpace),\n\t\t\t\t\tdecor.EwmaETA(decor.ET_STYLE_GO, 30),\n\t\t\t\t\tdecor.Name(\" | \", decor.WCSyncSpace),\n\t\t\t\t\tdecor.Name(\"SPD \", decor.WCSyncSpace),\n\t\t\t\t\tdecor.EwmaSpeed(decor.SizeB1024(0), \"% .2f\", 60),\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\t// start downloading\n\t\terr = warp.f.Download(path, bar)\n\t\t// if hasn't error then remove flag file\n\t\tif err == nil {\n\t\t\tif pb == nil {\n\t\t\t\tfmt.Println(\"Download\", warp.f.Name, \"Success\")\n\t\t\t}\n\t\t\tos.Remove(flag)\n\t\t\tif bar != nil {\n\t\t\t\tbar.SetTotal(siz, true)\n\t\t\t}\n\t\t} else {\n\t\t\tif pb == nil {\n\t\t\t\tfmt.Println(\"Download failed:\", warp.f.Name)\n\t\t\t\tlogx.Error(err)\n\t\t\t}\n\t\t\tif bar != nil {\n\t\t\t\tbar.Abort(false)\n\t\t\t}\n\t\t}\n\t\tout <- struct{}{}\n\t}\n}\n"
  },
  {
    "path": "cli/download/download_test.go",
    "content": "package download\n\nimport (\n\t\"errors\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype fakeTargetResolver struct {\n\tgetFileByPath func(path string) (api.FileStat, error)\n\tgetFileStat   func(parentId string, name string) (api.FileStat, error)\n\tgetPathFolder func(dirPath string) (string, error)\n}\n\nfunc (f fakeTargetResolver) GetFileByPath(path string) (api.FileStat, error) {\n\treturn f.getFileByPath(path)\n}\n\nfunc (f fakeTargetResolver) GetFileStat(parentId string, name string) (api.FileStat, error) {\n\treturn f.getFileStat(parentId, name)\n}\n\nfunc (f fakeTargetResolver) GetPathFolderId(dirPath string) (string, error) {\n\treturn f.getPathFolder(dirPath)\n}\n\nfunc TestRemoteTargetPathJoinsBasePath(t *testing.T) {\n\toriginalFolder := folder\n\tt.Cleanup(func() {\n\t\tfolder = originalFolder\n\t})\n\n\tfolder = \"/Movies\"\n\trequire.Equal(t, filepath.Clean(\"/Movies/Kids/Peppa.mp4\"), remoteTargetPath(\"Kids/Peppa.mp4\"))\n\trequire.Equal(t, filepath.Clean(\"/TV\"), remoteTargetPath(\"/TV\"))\n}\n\nfunc TestResolveDownloadTargetUsesParentIDForDirectChild(t *testing.T) {\n\toriginalFolder := folder\n\toriginalParentID := parentId\n\tt.Cleanup(func() {\n\t\tfolder = originalFolder\n\t\tparentId = originalParentID\n\t})\n\n\tfolder = \"/Movies\"\n\tparentId = \"parent-123\"\n\n\tresolver := fakeTargetResolver{\n\t\tgetFileStat: func(gotParentID string, gotName string) (api.FileStat, error) {\n\t\t\trequire.Equal(t, \"parent-123\", gotParentID)\n\t\t\trequire.Equal(t, \"Peppa.mp4\", gotName)\n\t\t\treturn api.FileStat{ID: \"file-1\", Name: \"Peppa.mp4\"}, nil\n\t\t},\n\t\tgetFileByPath: func(path string) (api.FileStat, error) {\n\t\t\treturn api.FileStat{}, errors.New(\"should not resolve by path\")\n\t\t},\n\t\tgetPathFolder: func(dirPath string) (string, error) {\n\t\t\treturn \"\", errors.New(\"should not resolve folder id\")\n\t\t},\n\t}\n\n\tstat, err := resolveDownloadTarget(resolver, \"Peppa.mp4\")\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"file-1\", stat.ID)\n}\n\nfunc TestResolveDownloadTargetJoinsBasePathForNestedArg(t *testing.T) {\n\toriginalFolder := folder\n\toriginalParentID := parentId\n\tt.Cleanup(func() {\n\t\tfolder = originalFolder\n\t\tparentId = originalParentID\n\t})\n\n\tfolder = \"/Movies\"\n\tparentId = \"parent-123\"\n\n\tresolver := fakeTargetResolver{\n\t\tgetFileStat: func(parentId string, name string) (api.FileStat, error) {\n\t\t\treturn api.FileStat{}, errors.New(\"should not resolve direct child\")\n\t\t},\n\t\tgetFileByPath: func(path string) (api.FileStat, error) {\n\t\t\trequire.Equal(t, filepath.Clean(\"/Movies/Kids/Peppa.mp4\"), path)\n\t\t\treturn api.FileStat{ID: \"file-2\", Name: \"Peppa.mp4\"}, nil\n\t\t},\n\t\tgetPathFolder: func(dirPath string) (string, error) {\n\t\t\treturn \"\", errors.New(\"should not resolve folder id\")\n\t\t},\n\t}\n\n\tstat, err := resolveDownloadTarget(resolver, \"Kids/Peppa.mp4\")\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"file-2\", stat.ID)\n}\n\nfunc TestResolveDownloadTargetWithoutArgsUsesBaseFolder(t *testing.T) {\n\toriginalFolder := folder\n\toriginalParentID := parentId\n\tt.Cleanup(func() {\n\t\tfolder = originalFolder\n\t\tparentId = originalParentID\n\t})\n\n\tfolder = \"/Movies\"\n\tparentId = \"\"\n\n\tresolver := fakeTargetResolver{\n\t\tgetFileByPath: func(path string) (api.FileStat, error) {\n\t\t\trequire.Equal(t, filepath.Clean(\"/Movies\"), path)\n\t\t\treturn api.FileStat{Kind: api.FileKindFolder, ID: \"folder-1\", Name: \"Movies\"}, nil\n\t\t},\n\t\tgetFileStat: func(parentId string, name string) (api.FileStat, error) {\n\t\t\treturn api.FileStat{}, errors.New(\"should not resolve by parent id\")\n\t\t},\n\t\tgetPathFolder: func(dirPath string) (string, error) {\n\t\t\treturn \"\", errors.New(\"should not resolve folder id\")\n\t\t},\n\t}\n\n\tstat, err := resolveDownloadTarget(resolver, \"\")\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"folder-1\", stat.ID)\n\trequire.Equal(t, api.FileKindFolder, stat.Kind)\n}\n\nfunc TestRequiresExplicitOutputFlag(t *testing.T) {\n\tcmd := &cobra.Command{}\n\tcmd.Flags().StringP(\"output\", \"o\", \".\", \"\")\n\n\trequire.False(t, requiresExplicitOutputFlag(cmd, []string{\".\"}))\n\trequire.True(t, requiresExplicitOutputFlag(cmd, []string{\"file.txt\", \".\"}))\n\trequire.True(t, requiresExplicitOutputFlag(cmd, []string{\"file.txt\", \"..\"}))\n\trequire.False(t, requiresExplicitOutputFlag(cmd, []string{\"file.txt\"}))\n\n\trequire.NoError(t, cmd.Flags().Set(\"output\", \".\"))\n\trequire.False(t, requiresExplicitOutputFlag(cmd, []string{\"file.txt\", \".\"}))\n}\n"
  },
  {
    "path": "cli/download/progress_test.go",
    "content": "package download\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestTrimRunes(t *testing.T) {\n\trequire.Equal(t, \"abcdef\", trimRunes(\"abcdef\", 6))\n\trequire.Equal(t, \"你好世...\", trimRunes(\"你好世界欢迎你\", 6))\n}\n\nfunc TestProgressDisplayNameIncludesParentDir(t *testing.T) {\n\twarp := warpFile{\n\t\tf:      &api.File{FileStat: api.FileStat{Name: \"Peppa.mp4\"}},\n\t\toutput: filepath.Join(\"Film\", \"Kids\"),\n\t}\n\n\trequire.Equal(t, \"Kids/Peppa.mp4\", progressDisplayName(warp))\n}\n"
  },
  {
    "path": "cli/empty/empty.go",
    "content": "package empty\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar targetPath string\nvar concurrency int\nvar deleteMode bool\n\ntype emptyFolderProvider interface {\n\tGetPathFolderId(dirPath string) (string, error)\n\tGetFolderFileStatList(parentId string) ([]api.FileStat, error)\n\tDeleteFile(fileId string) error\n}\n\nvar EmptyCmd = &cobra.Command{\n\tUse:   \"empty [path]\",\n\tShort: \"Recursively list empty folders on the PikPak server\",\n\tArgs:  cobra.MaximumNArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tpath := targetPath\n\t\tif len(args) > 0 {\n\t\t\tpath = args[0]\n\t\t}\n\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\tif err := p.Login(); err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\n\t\temptyFolders, err := handleEmptyFolders(cmd.Context(), &p, path, concurrency, deleteMode)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, context.Canceled) {\n\t\t\t\tfmt.Println(\"Empty folder scan canceled\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Println(\"Handle empty folders failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tif len(emptyFolders) == 0 {\n\t\t\tfmt.Printf(\"No empty folders found under %s\\n\", path)\n\t\t\treturn\n\t\t}\n\t\tfor _, folder := range emptyFolders {\n\t\t\tif deleteMode {\n\t\t\t\tfmt.Printf(\"Deleted empty folder: %s\\n\", folder)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfmt.Printf(\"Empty folder: %s\\n\", folder)\n\t\t}\n\t},\n}\n\nfunc init() {\n\tEmptyCmd.Flags().StringVarP(&targetPath, \"path\", \"p\", \"/\", \"The path where to remove empty folders recursively\")\n\tEmptyCmd.Flags().IntVarP(&concurrency, \"concurrency\", \"c\", 8, \"number of folders to process concurrently\")\n\tEmptyCmd.Flags().BoolVarP(&deleteMode, \"delete\", \"d\", false, \"delete the empty folders instead of only listing them\")\n}\n\nfunc handleEmptyFolders(ctx context.Context, p emptyFolderProvider, rootPath string, concurrency int, deleteMode bool) ([]string, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif err := ctx.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\trootID, err := p.GetPathFolderId(rootPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif concurrency < 1 {\n\t\tconcurrency = 1\n\t}\n\n\tdeleted := make([]string, 0)\n\tstate := emptyWalkState{\n\t\tsem: make(chan struct{}, concurrency),\n\t}\n\tif _, err := walkEmptyFolders(ctx, p, rootID, filepath.Clean(rootPath), filepath.Clean(rootPath) != string(filepath.Separator), deleteMode, &deleted, &state); err != nil {\n\t\treturn nil, err\n\t}\n\treturn deleted, nil\n}\n\ntype emptyWalkState struct {\n\tsem chan struct{}\n\tmu  sync.Mutex\n}\n\ntype emptyFolderResult struct {\n\tempty bool\n\terr   error\n}\n\nfunc walkEmptyFolders(ctx context.Context, p emptyFolderProvider, folderID, currentPath string, allowDeleteCurrent bool, deleteMode bool, deleted *[]string, state *emptyWalkState) (bool, error) {\n\tif err := ctx.Err(); err != nil {\n\t\treturn false, err\n\t}\n\tfiles, err := p.GetFolderFileStatList(folderID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\thasFiles := false\n\thasRemainingFolders := false\n\tresults := make(chan emptyFolderResult, len(files))\n\tvar childFolders int\n\tfor _, file := range files {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tif file.Kind != api.FileKindFolder {\n\t\t\thasFiles = true\n\t\t\tcontinue\n\t\t}\n\t\tchildFolders++\n\n\t\tchildPath := filepath.Join(currentPath, file.Name)\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn false, ctx.Err()\n\t\tcase state.sem <- struct{}{}:\n\t\t\tgo func(file api.FileStat, childPath string) {\n\t\t\t\tdefer func() {\n\t\t\t\t\t<-state.sem\n\t\t\t\t}()\n\t\t\t\tchildEmpty, err := walkEmptyFolders(ctx, p, file.ID, childPath, true, deleteMode, deleted, state)\n\t\t\t\tresults <- emptyFolderResult{\n\t\t\t\t\tempty: childEmpty,\n\t\t\t\t\terr:   err,\n\t\t\t\t}\n\t\t\t}(file, childPath)\n\t\tdefault:\n\t\t\tchildEmpty, err := walkEmptyFolders(ctx, p, file.ID, childPath, true, deleteMode, deleted, state)\n\t\t\tresults <- emptyFolderResult{\n\t\t\t\tempty: childEmpty,\n\t\t\t\terr:   err,\n\t\t\t}\n\t\t}\n\t}\n\n\tfor i := 0; i < childFolders; i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn false, ctx.Err()\n\t\tcase result := <-results:\n\t\t\tif result.err != nil {\n\t\t\t\treturn false, result.err\n\t\t\t}\n\t\t\tif !result.empty {\n\t\t\t\thasRemainingFolders = true\n\t\t\t}\n\t\t}\n\t}\n\n\tisEmpty := !hasFiles && !hasRemainingFolders\n\tif !isEmpty {\n\t\treturn false, nil\n\t}\n\tif !allowDeleteCurrent {\n\t\treturn true, nil\n\t}\n\n\tif deleteMode {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tif err := p.DeleteFile(folderID); err != nil {\n\t\t\treturn false, err\n\t\t}\n\t}\n\tstate.mu.Lock()\n\t*deleted = append(*deleted, currentPath)\n\tstate.mu.Unlock()\n\treturn true, nil\n}\n"
  },
  {
    "path": "cli/empty/empty_test.go",
    "content": "package empty\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype fakeEmptyFolderProvider struct {\n\trootID       string\n\tpathToID     map[string]string\n\tfolders      map[string][]api.FileStat\n\tdeletedFiles []string\n\tmu           sync.Mutex\n}\n\nfunc (f *fakeEmptyFolderProvider) GetPathFolderId(dirPath string) (string, error) {\n\tif id, ok := f.pathToID[filepath.Clean(dirPath)]; ok {\n\t\treturn id, nil\n\t}\n\treturn \"\", errors.New(\"path not found\")\n}\n\nfunc (f *fakeEmptyFolderProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tfiles := f.folders[parentId]\n\tcloned := make([]api.FileStat, len(files))\n\tcopy(cloned, files)\n\treturn cloned, nil\n}\n\nfunc (f *fakeEmptyFolderProvider) DeleteFile(fileId string) error {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tf.deletedFiles = append(f.deletedFiles, fileId)\n\tfor parentID, files := range f.folders {\n\t\tfiltered := files[:0]\n\t\tfor _, file := range files {\n\t\t\tif file.ID != fileId {\n\t\t\t\tfiltered = append(filtered, file)\n\t\t\t}\n\t\t}\n\t\tf.folders[parentID] = filtered\n\t}\n\tdelete(f.folders, fileId)\n\treturn nil\n}\n\nfunc TestHandleEmptyFoldersDeletesNestedEmptyFolders(t *testing.T) {\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"root\": {\n\t\t\t\t{ID: \"movies\", Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t\t{ID: \"music\", Name: \"Music\", Kind: api.FileKindFolder},\n\t\t\t\t{ID: \"video\", Name: \"video.mp4\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t\t\"movies\": {\n\t\t\t\t{ID: \"kids\", Name: \"Kids\", Kind: api.FileKindFolder},\n\t\t\t},\n\t\t\t\"kids\":  {},\n\t\t\t\"music\": {},\n\t\t},\n\t}\n\n\tdeleted, err := handleEmptyFolders(context.Background(), provider, \"/\", 4, true)\n\trequire.NoError(t, err)\n\tassert.ElementsMatch(t, []string{filepath.Clean(\"/Movies/Kids\"), filepath.Clean(\"/Movies\"), filepath.Clean(\"/Music\")}, deleted)\n\tassert.ElementsMatch(t, []string{\"kids\", \"movies\", \"music\"}, provider.deletedFiles)\n}\n\nfunc TestHandleEmptyFoldersSkipsNonEmptyRootTarget(t *testing.T) {\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/Movies\"): \"movies\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies\": {\n\t\t\t\t{ID: \"episode\", Name: \"episode.mkv\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t}\n\n\tdeleted, err := handleEmptyFolders(context.Background(), provider, \"/Movies\", 4, true)\n\trequire.NoError(t, err)\n\tassert.Empty(t, deleted)\n\tassert.Empty(t, provider.deletedFiles)\n}\n\nfunc TestHandleEmptyFoldersDeletesTargetWhenItBecomesEmpty(t *testing.T) {\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/Movies\"): \"movies\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies\": {\n\t\t\t\t{ID: \"kids\", Name: \"Kids\", Kind: api.FileKindFolder},\n\t\t\t},\n\t\t\t\"kids\": {},\n\t\t},\n\t}\n\n\tdeleted, err := handleEmptyFolders(context.Background(), provider, \"/Movies\", 4, true)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []string{filepath.Clean(\"/Movies/Kids\"), filepath.Clean(\"/Movies\")}, deleted)\n\tassert.Equal(t, []string{\"kids\", \"movies\"}, provider.deletedFiles)\n}\n\nfunc TestHandleEmptyFoldersNormalizesInvalidConcurrency(t *testing.T) {\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/Movies\"): \"movies\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies\": {},\n\t\t},\n\t}\n\n\tdeleted, err := handleEmptyFolders(context.Background(), provider, \"/Movies\", 0, true)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []string{filepath.Clean(\"/Movies\")}, deleted)\n\tassert.Equal(t, []string{\"movies\"}, provider.deletedFiles)\n}\n\nfunc TestHandleEmptyFoldersListsWithoutDeleting(t *testing.T) {\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"root\": {\n\t\t\t\t{ID: \"movies\", Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t},\n\t\t\t\"movies\": {},\n\t\t},\n\t}\n\n\temptyFolders, err := handleEmptyFolders(context.Background(), provider, \"/\", 4, false)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []string{filepath.Clean(\"/Movies\")}, emptyFolders)\n\tassert.Empty(t, provider.deletedFiles)\n}\n\ntype blockingEmptyFolderProvider struct {\n\tfakeEmptyFolderProvider\n\tblock chan struct{}\n}\n\nfunc (f *blockingEmptyFolderProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {\n\tif parentId == \"slow\" {\n\t\t<-f.block\n\t}\n\treturn f.fakeEmptyFolderProvider.GetFolderFileStatList(parentId)\n}\n\nfunc TestHandleEmptyFoldersHonorsCanceledContext(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tprovider := &fakeEmptyFolderProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"root\": {},\n\t\t},\n\t}\n\n\tdeleted, err := handleEmptyFolders(ctx, provider, \"/\", 4, false)\n\trequire.ErrorIs(t, err, context.Canceled)\n\tassert.Nil(t, deleted)\n}\n\nfunc TestHandleEmptyFoldersStopsWaitingAfterCancel(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tprovider := &blockingEmptyFolderProvider{\n\t\tfakeEmptyFolderProvider: fakeEmptyFolderProvider{\n\t\t\tpathToID: map[string]string{\n\t\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t\t},\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"root\": {\n\t\t\t\t\t{ID: \"slow\", Name: \"slow\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t\t\"slow\": {},\n\t\t\t},\n\t\t},\n\t\tblock: make(chan struct{}),\n\t}\n\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := handleEmptyFolders(ctx, provider, \"/\", 4, false)\n\t\tdone <- err\n\t}()\n\n\tcancel()\n\n\tselect {\n\tcase err := <-done:\n\t\trequire.ErrorIs(t, err, context.Canceled)\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"handleEmptyFolders did not stop promptly after cancellation\")\n\t}\n\n\tclose(provider.block)\n}\n"
  },
  {
    "path": "cli/list/list.go",
    "content": "package list\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/fatih/color\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar long bool\nvar human bool\nvar path string\nvar parentId string\n\nvar ListCmd = &cobra.Command{\n\tUse:   \"ls\",\n\tShort: `Get the directory information under the specified folder`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tlong, _ := cmd.Flags().GetBool(\"long\")\n\t\thuman, _ := cmd.Flags().GetBool(\"human\")\n\t\tpath, _ := cmd.Flags().GetString(\"path\")\n\t\tparentId, _ := cmd.Flags().GetString(\"parent-id\")\n\t\thandle(&p, args, long, human, path, parentId)\n\t},\n}\n\nfunc init() {\n\tListCmd.Flags().BoolVarP(&human, \"human\", \"H\", false, \"display human readable format\")\n\tListCmd.Flags().BoolVarP(&long, \"long\", \"l\", false, \"display long format\")\n\tListCmd.Flags().StringVarP(&path, \"path\", \"p\", \"/\", \"display the specified path\")\n\tListCmd.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"display the specified parent id\")\n}\n\nfunc handle(p *api.PikPak, args []string, long, human bool, path, parentId string) {\n\tvar err error\n\tif len(args) > 0 {\n\t\tpath = args[0]\n\t}\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetPathFolderId(path)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get path folder id error\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\tfiles, err := p.GetFolderFileStatList(parentId)\n\tif err != nil {\n\t\tfmt.Println(\"Get folder file stat list error\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tfor _, file := range files {\n\t\tif long {\n\t\t\tdisplay(2, &file)\n\t\t} else {\n\t\t\tdisplay(0, &file)\n\t\t}\n\t}\n}\n\n// mode 0: normal print\n// mode 2: long format\nfunc display(mode int, file *api.FileStat) {\n\tsize := utils.FormatStorage(file.Size, human)\n\n\tswitch mode {\n\tcase 0:\n\t\tif file.Kind == api.FileKindFolder {\n\t\t\tfmt.Printf(\"%-20s\\n\", color.GreenString(file.Name))\n\t\t} else {\n\t\t\tfmt.Printf(\"%-20s\\n\", file.Name)\n\t\t}\n\tcase 2:\n\t\tif file.Kind == api.FileKindFolder {\n\t\t\tfmt.Printf(\"%-26s %-8s %-19s %s\\n\", file.ID, size, file.CreatedTime.Format(\"2006-01-02 15:04:05\"), color.GreenString(file.Name))\n\t\t} else {\n\t\t\tfmt.Printf(\"%-26s %-8s %-19s %s\\n\", file.ID, size, file.CreatedTime.Format(\"2006-01-02 15:04:05\"), file.Name)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cli/new/folder/folder.go",
    "content": "package folder\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar NewFolderCommand = &cobra.Command{\n\tUse:   \"folder\",\n\tShort: `Create a folder to pikpak server`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tif len(args) > 0 {\n\t\t\thandleNewFolder(&p, args)\n\t\t} else {\n\t\t\tfmt.Println(\"Please input the folder name\")\n\t\t}\n\t},\n}\n\nvar path string\nvar parentId string\n\nfunc init() {\n\tNewFolderCommand.Flags().StringVarP(&path, \"path\", \"p\", \"/\", \"The path of the folder\")\n\tNewFolderCommand.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"The parent id\")\n}\n\n// new folder\nfunc handleNewFolder(p *api.PikPak, folders []string) {\n\tbaseParentID := parentId\n\tvar err error\n\tif baseParentID == \"\" {\n\t\tbaseParentID, err = p.GetPathFolderId(path)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tfor _, folder := range folders {\n\t\tfolder = strings.TrimSpace(folder)\n\t\tif folder == \"\" {\n\t\t\tfmt.Println(\"Folder name cannot be empty\")\n\t\t\tcontinue\n\t\t}\n\n\t\tcleanFolder := filepath.Clean(folder)\n\t\tif cleanFolder == \".\" || cleanFolder == string(filepath.Separator) {\n\t\t\tfmt.Printf(\"Folder path is invalid: %s\\n\", folder)\n\t\t\tcontinue\n\t\t}\n\n\t\tcreateParentID := baseParentID\n\t\tif filepath.IsAbs(cleanFolder) {\n\t\t\tcreateParentID = \"\"\n\t\t}\n\n\t\t_, err := p.GetDeepFolderOrCreateId(createParentID, cleanFolder)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Create folder %s failed\\n\", folder)\n\t\t\tlogx.Error(err)\n\t\t} else {\n\t\t\tfmt.Printf(\"Create folder %s success\\n\", folder)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cli/new/new.go",
    "content": "package new\n\nimport (\n\t\"github.com/52funny/pikpakcli/cli/new/folder\"\n\t\"github.com/52funny/pikpakcli/cli/new/sha\"\n\t\"github.com/52funny/pikpakcli/cli/new/url\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar NewCommand = &cobra.Command{\n\tUse:     \"new\",\n\tAliases: []string{\"n\"},\n\tShort:   `New can do something like create folder or other things`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcmd.Help()\n\t},\n}\n\nfunc init() {\n\tNewCommand.AddCommand(folder.NewFolderCommand)\n\tNewCommand.AddCommand(sha.NewShaCommand)\n\tNewCommand.AddCommand(url.NewUrlCommand)\n}\n"
  },
  {
    "path": "cli/new/sha/sha.go",
    "content": "package sha\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar NewShaCommand = &cobra.Command{\n\tUse:   \"sha\",\n\tShort: `Create a file according to sha`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\t// input mode\n\t\tif strings.TrimSpace(input) != \"\" {\n\t\t\tf, err := os.OpenFile(input, os.O_RDONLY, 0666)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Open file %s failed\\n\", input)\n\t\t\t\tlogx.Error(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\treader := bufio.NewReader(f)\n\t\t\tshas := make([]string, 0)\n\t\t\tfor {\n\t\t\t\tlineBytes, _, err := reader.ReadLine()\n\t\t\t\tif err == io.EOF {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tshas = append(shas, string(lineBytes))\n\t\t\t}\n\t\t\thandleNewSha(&p, shas)\n\t\t\treturn\n\t\t}\n\n\t\t// args mode\n\t\tif len(args) > 0 {\n\t\t\thandleNewSha(&p, args)\n\t\t} else {\n\t\t\tfmt.Println(\"Please input the folder name\")\n\t\t}\n\t},\n}\n\nvar path string\n\nvar parentId string\n\nvar input string\n\nfunc init() {\n\tNewShaCommand.Flags().StringVarP(&path, \"path\", \"p\", \"/\", \"The path of the folder\")\n\tNewShaCommand.Flags().StringVarP(&input, \"input\", \"i\", \"\", \"The input of the sha file\")\n\tNewShaCommand.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"The parent id\")\n}\n\n// new folder\nfunc handleNewSha(p *api.PikPak, shas []string) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetPathFolderId(path)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tfor _, sha := range shas {\n\t\tsha = sha[strings.Index(sha, \"://\")+3:]\n\t\tshaElements := strings.Split(sha, \"|\")\n\t\tif len(shaElements) != 3 {\n\t\t\tfmt.Println(\"The sha format is wrong:\", sha)\n\t\t\tcontinue\n\t\t}\n\t\tname, size, sha := shaElements[0], shaElements[1], shaElements[2]\n\t\terr := p.CreateShaFile(parentId, name, size, sha)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Create sha file failed\")\n\t\t\tlogx.Error(err)\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Println(\"Create sha file success:\", name)\n\t}\n}\n"
  },
  {
    "path": "cli/new/url/url.go",
    "content": "package url\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar NewUrlCommand = &cobra.Command{\n\tUse:   \"url\",\n\tShort: `Create a file according to url`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tif cli {\n\t\t\thandleCli(&p)\n\t\t\treturn\n\t\t}\n\t\t// input mode\n\t\tif strings.TrimSpace(input) != \"\" {\n\t\t\tf, err := os.OpenFile(input, os.O_RDONLY, 0666)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Open file %s failed\\n\", input)\n\t\t\t\tlogx.Error(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\treader := bufio.NewReader(f)\n\t\t\tshas := make([]string, 0)\n\t\t\tfor {\n\t\t\t\tlineBytes, _, err := reader.ReadLine()\n\t\t\t\tif err == io.EOF {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tshas = append(shas, string(lineBytes))\n\t\t\t}\n\t\t\thandleNewUrl(&p, shas)\n\t\t\treturn\n\t\t}\n\n\t\t// args mode\n\t\tif len(args) > 0 {\n\t\t\thandleNewUrl(&p, args)\n\t\t} else {\n\t\t\tfmt.Println(\"Please input the folder name\")\n\t\t}\n\t},\n}\n\nvar path string\n\nvar parentId string\n\nvar input string\n\nvar cli bool\n\nfunc init() {\n\tNewUrlCommand.Flags().StringVarP(&path, \"path\", \"p\", \"/\", \"The path of the folder\")\n\tNewUrlCommand.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"The parent id\")\n\tNewUrlCommand.Flags().StringVarP(&input, \"input\", \"i\", \"\", \"The input of the sha file\")\n\tNewUrlCommand.Flags().BoolVarP(&cli, \"cli\", \"c\", false, \"The cli mode\")\n}\n\n// new folder\nfunc handleNewUrl(p *api.PikPak, shas []string) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetPathFolderId(path)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\tfor _, url := range shas {\n\t\terr := p.CreateUrlFile(parentId, url)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Create url file failed\")\n\t\t\tlogx.Error(err)\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Println(\"Create url file success:\", url)\n\t}\n}\n\nfunc handleCli(p *api.PikPak) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetPathFolderId(path)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\treader := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tfmt.Print(\"> \")\n\t\tlineBytes, _, err := reader.ReadLine()\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\turl := string(lineBytes)\n\t\terr = p.CreateUrlFile(parentId, url)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Create url file failed\")\n\t\t\tlogx.Error(err)\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Println(\"Create url file success:\", url)\n\t}\n}\n"
  },
  {
    "path": "cli/quota/quota.go",
    "content": "package quota\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar human bool\n\nvar QuotaCmd = &cobra.Command{\n\tUse:   \"quota\",\n\tShort: `Get the quota for the pikpak cloud`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tq, err := p.GetQuota()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get cloud quota error\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tfmt.Println(\"Storage:\")\n\t\tfmt.Printf(\"%-20s%-20s\\n\", \"total\", \"used\")\n\t\tif human {\n\t\t\tfmt.Printf(\"%-20s%-20s\\n\", utils.FormatStorage(q.Quota.Limit, true), utils.FormatStorage(q.Quota.Usage, true))\n\t\t} else {\n\t\t\tfmt.Printf(\"%-20s%-20s\\n\", q.Quota.Limit, q.Quota.Usage)\n\t\t}\n\n\t\tdisplayCloudDownload(q.Quotas.CloudDownload)\n\n\t\ttransfer, err := p.GetTransferQuota()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get transfer quota error\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tdisplayMonthlyTransferQuota(transfer.Base)\n\t},\n}\n\nfunc init() {\n\tQuotaCmd.Flags().BoolVarP(&human, \"human\", \"H\", false, \"display human readable format\")\n}\n\nfunc displayCloudDownload(cloudDownload api.Quota) {\n\tfmt.Printf(\"\\ncloud download:\\n\")\n\tfmt.Printf(\"%-20s%-20s%-20s\\n\", \"total\", \"used\", \"remaining\")\n\tremaining, err := cloudDownload.Remaining()\n\tif err != nil {\n\t\tfmt.Printf(\"%-20s%-20s%-20s\\n\", formatQuotaValue(cloudDownload.Limit), formatQuotaValue(cloudDownload.Usage), \"N/A\")\n\t\treturn\n\t}\n\tfmt.Printf(\"%-20s%-20s%-20s\\n\", formatQuotaValue(cloudDownload.Limit), formatQuotaValue(cloudDownload.Usage), formatTransferValue(remaining))\n}\n\nfunc displayMonthlyTransferQuota(base api.TransferQuotaBase) {\n\tfmt.Printf(\"\\nmonthly transfer:\\n\")\n\tfmt.Printf(\"%-20s%-20s%-20s%-20s\\n\", \"type\", \"total\", \"used\", \"remaining\")\n\tdisplayTransferRow(\"cloud download\", base.Offline)\n\tdisplayTransferRow(\"download\", base.Download)\n\tdisplayTransferRow(\"upload\", base.Upload)\n}\n\nfunc displayTransferRow(name string, quota api.TransferQuota) {\n\tfmt.Printf(\n\t\t\"%-20s%-20s%-20s%-20s\\n\",\n\t\tname,\n\t\tformatTransferValue(quota.TotalAssets),\n\t\tformatTransferValue(quota.Assets),\n\t\tformatTransferValue(quota.Remaining()),\n\t)\n}\n\nfunc formatTransferValue(size int64) string {\n\treturn utils.FormatStorage(fmt.Sprintf(\"%d\", size), human)\n}\n\nfunc formatQuotaValue(size string) string {\n\treturn utils.FormatStorage(size, human)\n}\n"
  },
  {
    "path": "cli/quota/quota_test.go",
    "content": "package quota\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFormatTransferValue(t *testing.T) {\n\thuman = false\n\tassert.Equal(t, \"2048\", formatTransferValue(2048))\n\n\thuman = true\n\tassert.Equal(t, \"2KB\", formatTransferValue(2048))\n}\n"
  },
  {
    "path": "cli/rename/rename.go",
    "content": "package rename\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar RenameCmd = &cobra.Command{\n\tUse:   \"rename <path> <new-name>\",\n\tShort: \"Rename a file or folder on the PikPak drive\",\n\tLong: `Rename a file or folder on the PikPak drive. \nExample: pikpakcli rename /my-folder/old-name.txt new-name.txt`,\n\tArgs: cobra.ExactArgs(2),\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\tif err := p.Login(); err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn nil\n\t\t}\n\n\t\toldPath := args[0]\n\t\tnewName := strings.TrimSpace(args[1])\n\t\tif newName == \"\" {\n\t\t\treturn fmt.Errorf(\"new name cannot be empty\")\n\t\t}\n\t\tif filepath.Base(newName) != newName {\n\t\t\treturn fmt.Errorf(\"new name must not contain path separators\")\n\t\t}\n\n\t\texpandedPaths, err := api.ExpandRemotePatterns(&p, \"/\", []string{oldPath}, false)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Could not find file or folder at path '%s'\\n\", oldPath)\n\t\t\tlogx.Error(err)\n\t\t\treturn nil\n\t\t}\n\t\tif len(expandedPaths) != 1 {\n\t\t\treturn fmt.Errorf(\"rename target must match exactly one path\")\n\t\t}\n\t\toldPath = expandedPaths[0]\n\n\t\tfileStat, err := p.GetFileByPath(oldPath)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Could not find file or folder at path '%s'\\n\", oldPath)\n\t\t\tlogx.Error(err)\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := p.Rename(fileStat.ID, newName); err != nil {\n\t\t\tfmt.Printf(\"Failed to rename %s\\n\", oldPath)\n\t\t\tlogx.Error(err)\n\t\t\treturn nil\n\t\t}\n\n\t\tfmt.Printf(\"Successfully renamed '%s' to '%s'\\n\", oldPath, newName)\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "cli/root.go",
    "content": "package cli\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tdel \"github.com/52funny/pikpakcli/cli/del\"\n\t\"github.com/52funny/pikpakcli/cli/download\"\n\t\"github.com/52funny/pikpakcli/cli/empty\"\n\t\"github.com/52funny/pikpakcli/cli/list\"\n\t\"github.com/52funny/pikpakcli/cli/new\"\n\t\"github.com/52funny/pikpakcli/cli/quota\"\n\t\"github.com/52funny/pikpakcli/cli/rename\"\n\t\"github.com/52funny/pikpakcli/cli/rubbish\"\n\t\"github.com/52funny/pikpakcli/cli/share\"\n\t\"github.com/52funny/pikpakcli/cli/upload\"\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar rootCmd = &cobra.Command{\n\tUse:   \"pikpakcli\",\n\tShort: \"Pikpakcli is a command line interface for Pikpak\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tcmd.Help()\n\t},\n\tPersistentPreRun: func(cmd *cobra.Command, args []string) {\n\t\terr := conf.InitConfig(configPath)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Init config failed\")\n\t\t\tlogx.Error(err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tlogx.Init(debug, debugTopics)\n\t},\n}\n\n// Config path\nvar configPath string\n\n// Debug mode\nvar debug bool\nvar debugTopics []string\n\n// Initialize the command line interface\nfunc init() {\n\trootCmd.PersistentFlags().BoolVar(&debug, \"debug\", false, \"debug mode\")\n\trootCmd.PersistentFlags().StringSliceVar(&debugTopics, \"debug-topic\", nil, \"enable debug topics: api,session,transfer\")\n\trootCmd.PersistentFlags().StringVar(&configPath, \"config\", \"config.yml\", \"config file path\")\n\trootCmd.AddCommand(upload.UploadCmd)\n\trootCmd.AddCommand(download.DownloadCmd)\n\trootCmd.AddCommand(share.ShareCommand)\n\trootCmd.AddCommand(new.NewCommand)\n\trootCmd.AddCommand(quota.QuotaCmd)\n\trootCmd.AddCommand(list.ListCmd)\n\trootCmd.AddCommand(del.DeleteCmd)\n\trootCmd.AddCommand(empty.EmptyCmd)\n\trootCmd.AddCommand(rubbish.RubbishCmd)\n\trootCmd.AddCommand(rename.RenameCmd)\n\trootCmd.AddCommand(shellCmd)\n}\n\n// Execute the command line interface\nfunc Execute() {\n\tif err := rootCmd.Execute(); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cli/rubbish/rubbish.go",
    "content": "package rubbish\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar rubbishPath string\nvar rulesPath string\nvar rubbishConcurrency int\nvar rubbishDeleteMode bool\nvar openRulesFile bool\nvar openRulesDir bool\nvar downloadRules bool\n\nconst (\n\tdefaultRulesRelativePath = \"rules/rubbish_rules.txt\"\n\tdefaultRulesDownloadURL  = \"https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt\"\n)\n\ntype rubbishProvider interface {\n\tGetPathFolderId(dirPath string) (string, error)\n\tGetFolderFileStatList(parentId string) ([]api.FileStat, error)\n\tDeleteFile(fileId string) error\n}\n\ntype compiledRules struct {\n\tincludes []string\n\texcludes []string\n}\n\ntype rubbishMatch struct {\n\tpath    string\n\tpattern string\n}\n\ntype rubbishWalkState struct {\n\tsem chan struct{}\n\tmu  sync.Mutex\n}\n\ntype rubbishFolderResult struct {\n\tmatches []rubbishMatch\n\terr     error\n}\n\nvar RubbishCmd = &cobra.Command{\n\tUse:   \"rubbish [path]\",\n\tShort: \"Recursively find rubbish files on the PikPak server using text rules\",\n\tArgs:  cobra.MaximumNArgs(1),\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tpath := rubbishPath\n\t\tif len(args) > 0 {\n\t\t\tpath = args[0]\n\t\t}\n\n\t\tresolvedRulesPath, err := resolveRulesPath(rulesPath)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Resolve rubbish rules failed: %v\\n\", err)\n\t\t\treturn\n\t\t}\n\n\t\tif downloadRules {\n\t\t\tif err := downloadDefaultRules(resolvedRulesPath, defaultRulesDownloadURL); err != nil {\n\t\t\t\tfmt.Printf(\"Download rubbish rules failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Printf(\"Downloaded rubbish rules to %s\\n\", resolvedRulesPath)\n\t\t}\n\n\t\tif openRulesDir {\n\t\t\tif err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {\n\t\t\t\tfmt.Printf(\"Prepare rubbish rules failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := openLocalPath(filepath.Dir(resolvedRulesPath)); err != nil {\n\t\t\t\tfmt.Printf(\"Open rules directory failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Printf(\"Opened rules directory: %s\\n\", filepath.Dir(resolvedRulesPath))\n\t\t\treturn\n\t\t}\n\n\t\tif openRulesFile {\n\t\t\tif err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {\n\t\t\t\tfmt.Printf(\"Prepare rubbish rules failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := openLocalPath(resolvedRulesPath); err != nil {\n\t\t\t\tfmt.Printf(\"Open rules file failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Printf(\"Opened rules file: %s\\n\", resolvedRulesPath)\n\t\t\treturn\n\t\t}\n\n\t\tif strings.TrimSpace(rulesPath) == \"\" {\n\t\t\tif err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {\n\t\t\t\tfmt.Printf(\"Prepare rubbish rules failed: %v\\n\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\trules, err := loadRules(resolvedRulesPath)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Load rubbish rules failed: %v\\n\", err)\n\t\t\treturn\n\t\t}\n\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\tif err := p.Login(); err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\n\t\tmatches, err := handleRubbish(cmd.Context(), &p, path, rules, rubbishConcurrency, rubbishDeleteMode)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, context.Canceled) {\n\t\t\t\tfmt.Println(\"Rubbish scan canceled\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfmt.Println(\"Handle rubbish failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\n\t\tif len(matches) == 0 {\n\t\t\tfmt.Printf(\"No rubbish files matched under %s\\n\", path)\n\t\t\treturn\n\t\t}\n\n\t\tfor _, match := range matches {\n\t\t\tif rubbishDeleteMode {\n\t\t\t\tfmt.Printf(\"Deleted rubbish: %s (matched %s)\\n\", match.path, match.pattern)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfmt.Printf(\"Rubbish file: %s (matched %s)\\n\", match.path, match.pattern)\n\t\t}\n\t},\n}\n\nfunc init() {\n\tRubbishCmd.Flags().StringVarP(&rubbishPath, \"path\", \"p\", \"/\", \"The path where to scan rubbish files recursively\")\n\tRubbishCmd.Flags().StringVar(&rulesPath, \"rules\", \"\", \"Path or URL to the rubbish rules file\")\n\tRubbishCmd.Flags().IntVarP(&rubbishConcurrency, \"concurrency\", \"c\", 8, \"number of folders to process concurrently\")\n\tRubbishCmd.Flags().BoolVarP(&rubbishDeleteMode, \"delete\", \"d\", false, \"delete matched rubbish files instead of only listing them\")\n\tRubbishCmd.Flags().BoolVar(&openRulesFile, \"open-rules\", false, \"Open the rubbish rules file, downloading the default file to the config directory when needed\")\n\tRubbishCmd.Flags().BoolVar(&openRulesDir, \"open-rules-dir\", false, \"Open the rubbish rules directory, downloading the default file to the config directory when needed\")\n\tRubbishCmd.Flags().BoolVar(&downloadRules, \"download-rules\", false, \"Download the default rubbish rules file from GitHub into the config directory before running\")\n}\n\nfunc loadRules(path string) (compiledRules, error) {\n\texpandedPath := utils.ExpandLocalPath(path)\n\tfile, err := os.Open(expandedPath)\n\tif err != nil {\n\t\treturn compiledRules{}, err\n\t}\n\tdefer file.Close()\n\n\tvar rules compiledRules\n\tscanner := bufio.NewScanner(file)\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\texclude := strings.HasPrefix(line, \"!\")\n\t\tif exclude {\n\t\t\tline = strings.TrimSpace(strings.TrimPrefix(line, \"!\"))\n\t\t}\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif exclude {\n\t\t\trules.excludes = append(rules.excludes, line)\n\t\t\tcontinue\n\t\t}\n\t\trules.includes = append(rules.includes, line)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn compiledRules{}, err\n\t}\n\tif len(rules.includes) == 0 {\n\t\treturn compiledRules{}, fmt.Errorf(\"no include rules found in %s\", expandedPath)\n\t}\n\treturn rules, nil\n}\n\nfunc resolveRulesPath(raw string) (string, error) {\n\ttrimmed := strings.TrimSpace(raw)\n\tif trimmed == \"\" {\n\t\treturn defaultRulesPath()\n\t}\n\tif isRemoteRulesSource(trimmed) {\n\t\ttarget, err := defaultRulesPath()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif err := downloadDefaultRules(target, trimmed); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn target, nil\n\t}\n\n\texpanded := utils.ExpandLocalPath(trimmed)\n\tinfo, err := os.Stat(expanded)\n\tif err == nil && info.IsDir() {\n\t\treturn filepath.Join(expanded, filepath.Base(defaultRulesRelativePath)), nil\n\t}\n\tif err != nil && !os.IsNotExist(err) {\n\t\treturn \"\", err\n\t}\n\treturn expanded, nil\n}\n\nfunc defaultRulesPath() (string, error) {\n\tconfigDir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get config dir error: %w\", err)\n\t}\n\treturn filepath.Join(configDir, \"pikpakcli\", defaultRulesRelativePath), nil\n}\n\nfunc ensureDefaultRulesFile(path string) error {\n\tif path == \"\" {\n\t\treturn errors.New(\"rules path cannot be empty\")\n\t}\n\tif _, err := os.Stat(path); err == nil {\n\t\treturn nil\n\t} else if !os.IsNotExist(err) {\n\t\treturn err\n\t}\n\treturn downloadDefaultRules(path, defaultRulesDownloadURL)\n}\n\nfunc downloadDefaultRules(targetPath string, sourceURL string) error {\n\tif err := utils.CreateDirIfNotExist(filepath.Dir(targetPath)); err != nil {\n\t\treturn err\n\t}\n\n\tresp, err := http.Get(sourceURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"download rules returned %s\", resp.Status)\n\t}\n\n\tbs, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := os.WriteFile(targetPath, bs, 0o644); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc isRemoteRulesSource(path string) bool {\n\treturn strings.HasPrefix(path, \"http://\") || strings.HasPrefix(path, \"https://\")\n}\n\nfunc openLocalPath(path string) error {\n\tname, args, err := buildLocalOpenCommand(runtime.GOOS, path)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn exec.Command(name, args...).Start()\n}\n\nfunc buildLocalOpenCommand(goos string, path string) (string, []string, error) {\n\tswitch goos {\n\tcase \"darwin\":\n\t\treturn \"open\", []string{path}, nil\n\tcase \"linux\":\n\t\treturn \"xdg-open\", []string{path}, nil\n\tcase \"windows\":\n\t\treturn \"cmd\", []string{\"/c\", \"start\", \"\", path}, nil\n\tdefault:\n\t\treturn \"\", nil, fmt.Errorf(\"unsupported platform: %s\", goos)\n\t}\n}\n\nfunc handleRubbish(ctx context.Context, p rubbishProvider, rootPath string, rules compiledRules, concurrency int, deleteMode bool) ([]rubbishMatch, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif err := ctx.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\trootID, err := p.GetPathFolderId(rootPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif concurrency < 1 {\n\t\tconcurrency = 1\n\t}\n\n\tmatches := make([]rubbishMatch, 0)\n\tstate := rubbishWalkState{\n\t\tsem: make(chan struct{}, concurrency),\n\t}\n\tif err := walkRubbish(ctx, p, rootID, filepath.Clean(rootPath), rules, deleteMode, &matches, &state); err != nil {\n\t\treturn nil, err\n\t}\n\treturn matches, nil\n}\n\nfunc walkRubbish(ctx context.Context, p rubbishProvider, folderID, currentPath string, rules compiledRules, deleteMode bool, matches *[]rubbishMatch, state *rubbishWalkState) error {\n\tif err := ctx.Err(); err != nil {\n\t\treturn err\n\t}\n\n\tfiles, err := p.GetFolderFileStatList(folderID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresults := make(chan rubbishFolderResult, len(files))\n\tvar childFolders int\n\tfor _, file := range files {\n\t\tif err := ctx.Err(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tchildPath := filepath.Join(currentPath, file.Name)\n\t\tif file.Kind == api.FileKindFolder {\n\t\t\tchildFolders++\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn ctx.Err()\n\t\t\tcase state.sem <- struct{}{}:\n\t\t\t\tgo func(file api.FileStat, childPath string) {\n\t\t\t\t\tdefer func() {\n\t\t\t\t\t\t<-state.sem\n\t\t\t\t\t}()\n\t\t\t\t\tlocalMatches := make([]rubbishMatch, 0)\n\t\t\t\t\terr := walkRubbish(ctx, p, file.ID, childPath, rules, deleteMode, &localMatches, state)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tif pattern, ok := rules.Match(childPath); ok {\n\t\t\t\t\t\t\tif deleteMode {\n\t\t\t\t\t\t\t\terr = p.DeleteFile(file.ID)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif err == nil {\n\t\t\t\t\t\t\t\tlocalMatches = append(localMatches, rubbishMatch{path: childPath, pattern: pattern})\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tresults <- rubbishFolderResult{matches: localMatches, err: err}\n\t\t\t\t}(file, childPath)\n\t\t\tdefault:\n\t\t\t\tlocalMatches := make([]rubbishMatch, 0)\n\t\t\t\tif err := walkRubbish(ctx, p, file.ID, childPath, rules, deleteMode, &localMatches, state); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif pattern, ok := rules.Match(childPath); ok {\n\t\t\t\t\tif deleteMode {\n\t\t\t\t\t\tif err := p.DeleteFile(file.ID); err != nil {\n\t\t\t\t\t\t\treturn err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tlocalMatches = append(localMatches, rubbishMatch{path: childPath, pattern: pattern})\n\t\t\t\t}\n\t\t\t\tresults <- rubbishFolderResult{matches: localMatches}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tpattern, ok := rules.Match(childPath)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif deleteMode {\n\t\t\tif err := p.DeleteFile(file.ID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tstate.mu.Lock()\n\t\t*matches = append(*matches, rubbishMatch{path: childPath, pattern: pattern})\n\t\tstate.mu.Unlock()\n\t}\n\n\tfor i := 0; i < childFolders; i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn ctx.Err()\n\t\tcase result := <-results:\n\t\t\tif result.err != nil {\n\t\t\t\treturn result.err\n\t\t\t}\n\t\t\tstate.mu.Lock()\n\t\t\t*matches = append(*matches, result.matches...)\n\t\t\tstate.mu.Unlock()\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r compiledRules) Match(path string) (string, bool) {\n\tnormalizedPath := filepath.Clean(path)\n\tif normalizedPath == \".\" {\n\t\tnormalizedPath = string(filepath.Separator)\n\t}\n\tname := filepath.Base(normalizedPath)\n\n\tfor _, pattern := range r.excludes {\n\t\tif patternMatches(pattern, normalizedPath, name) {\n\t\t\treturn \"\", false\n\t\t}\n\t}\n\tfor _, pattern := range r.includes {\n\t\tif patternMatches(pattern, normalizedPath, name) {\n\t\t\treturn pattern, true\n\t\t}\n\t}\n\treturn \"\", false\n}\n\nfunc patternMatches(pattern, fullPath, name string) bool {\n\tpattern = filepath.Clean(strings.TrimSpace(pattern))\n\tif pattern == \".\" {\n\t\treturn false\n\t}\n\n\tmatchTarget := name\n\tif strings.Contains(pattern, string(filepath.Separator)) {\n\t\tmatchTarget = strings.TrimPrefix(fullPath, string(filepath.Separator))\n\t\tif strings.HasPrefix(pattern, string(filepath.Separator)) {\n\t\t\tmatchTarget = fullPath\n\t\t}\n\t}\n\n\tif !hasWildcard(pattern) {\n\t\treturn matchTarget == pattern\n\t}\n\n\tmatched, err := filepath.Match(pattern, matchTarget)\n\treturn err == nil && matched\n}\n\nfunc hasWildcard(pattern string) bool {\n\treturn strings.ContainsAny(pattern, \"*?[\")\n}\n"
  },
  {
    "path": "cli/rubbish/rubbish_test.go",
    "content": "package rubbish\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype fakeRubbishProvider struct {\n\tpathToID     map[string]string\n\tfolders      map[string][]api.FileStat\n\tdeletedFiles []string\n\tmu           sync.Mutex\n}\n\nfunc (f *fakeRubbishProvider) GetPathFolderId(dirPath string) (string, error) {\n\tif id, ok := f.pathToID[filepath.Clean(dirPath)]; ok {\n\t\treturn id, nil\n\t}\n\treturn \"\", errors.New(\"path not found\")\n}\n\nfunc (f *fakeRubbishProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tfiles := f.folders[parentId]\n\tcloned := make([]api.FileStat, len(files))\n\tcopy(cloned, files)\n\treturn cloned, nil\n}\n\nfunc (f *fakeRubbishProvider) DeleteFile(fileId string) error {\n\tf.mu.Lock()\n\tdefer f.mu.Unlock()\n\tf.deletedFiles = append(f.deletedFiles, fileId)\n\tfor parentID, files := range f.folders {\n\t\tfiltered := files[:0]\n\t\tfor _, file := range files {\n\t\t\tif file.ID != fileId {\n\t\t\t\tfiltered = append(filtered, file)\n\t\t\t}\n\t\t}\n\t\tf.folders[parentID] = filtered\n\t}\n\tdelete(f.folders, fileId)\n\treturn nil\n}\n\nfunc TestLoadRules(t *testing.T) {\n\tdir := t.TempDir()\n\trulesFile := filepath.Join(dir, \"rules.txt\")\n\terr := os.WriteFile(rulesFile, []byte(\"# comment\\n\\n.DS_Store\\n*.tmp\\n!important.tmp\\n\"), 0o644)\n\trequire.NoError(t, err)\n\n\trules, err := loadRules(rulesFile)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []string{\".DS_Store\", \"*.tmp\"}, rules.includes)\n\tassert.Equal(t, []string{\"important.tmp\"}, rules.excludes)\n}\n\nfunc TestCompiledRulesMatch(t *testing.T) {\n\trules := compiledRules{\n\t\tincludes: []string{\".DS_Store\", \"*.tmp\", \"cache/*.part\", \"/System/*\"},\n\t\texcludes: []string{\"keep.tmp\", \"!ignored\", \"/System/keep/*\"},\n\t}\n\n\tpattern, ok := rules.Match(\"/Movies/.DS_Store\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \".DS_Store\", pattern)\n\n\tpattern, ok = rules.Match(\"/Movies/video.tmp\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"*.tmp\", pattern)\n\n\tpattern, ok = rules.Match(\"/cache/file.part\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"cache/*.part\", pattern)\n\n\t_, ok = rules.Match(\"/Movies/keep.tmp\")\n\tassert.False(t, ok)\n\n\tpattern, ok = rules.Match(\"/System/logs\")\n\trequire.True(t, ok)\n\tassert.Equal(t, \"/System/*\", pattern)\n\n\t_, ok = rules.Match(\"/System/keep/file\")\n\tassert.False(t, ok)\n}\n\nfunc TestHandleRubbishListsAndDeletesMatches(t *testing.T) {\n\tprovider := &fakeRubbishProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"root\": {\n\t\t\t\t{ID: \"movies\", Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t\t{ID: \"ds\", Name: \".DS_Store\", Kind: api.FileKindFile},\n\t\t\t\t{ID: \"keep\", Name: \"keep.tmp\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t\t\"movies\": {\n\t\t\t\t{ID: \"partial\", Name: \"video.part\", Kind: api.FileKindFile},\n\t\t\t\t{ID: \"poster\", Name: \"poster.jpg\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t}\n\n\trules := compiledRules{\n\t\tincludes: []string{\".DS_Store\", \"*.part\", \"*.tmp\"},\n\t\texcludes: []string{\"keep.tmp\"},\n\t}\n\n\tmatches, err := handleRubbish(context.Background(), provider, \"/\", rules, 4, false)\n\trequire.NoError(t, err)\n\tassert.ElementsMatch(t, []rubbishMatch{\n\t\t{path: filepath.Clean(\"/.DS_Store\"), pattern: \".DS_Store\"},\n\t\t{path: filepath.Clean(\"/Movies/video.part\"), pattern: \"*.part\"},\n\t}, matches)\n\tassert.Empty(t, provider.deletedFiles)\n\n\tmatches, err = handleRubbish(context.Background(), provider, \"/\", rules, 4, true)\n\trequire.NoError(t, err)\n\tassert.ElementsMatch(t, []rubbishMatch{\n\t\t{path: filepath.Clean(\"/.DS_Store\"), pattern: \".DS_Store\"},\n\t\t{path: filepath.Clean(\"/Movies/video.part\"), pattern: \"*.part\"},\n\t}, matches)\n\tassert.ElementsMatch(t, []string{\"ds\", \"partial\"}, provider.deletedFiles)\n}\n\nfunc TestHandleRubbishNormalizesConcurrency(t *testing.T) {\n\tprovider := &fakeRubbishProvider{\n\t\tpathToID: map[string]string{\n\t\t\tfilepath.Clean(\"/\"): \"root\",\n\t\t},\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"root\": {\n\t\t\t\t{ID: \"tmp\", Name: \"a.tmp\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t}\n\n\tmatches, err := handleRubbish(context.Background(), provider, \"/\", compiledRules{includes: []string{\"*.tmp\"}}, 0, false)\n\trequire.NoError(t, err)\n\tassert.Equal(t, []rubbishMatch{{path: filepath.Clean(\"/a.tmp\"), pattern: \"*.tmp\"}}, matches)\n}\n\nfunc TestDefaultRulesPathUsesConfigDir(t *testing.T) {\n\tconfigDir, err := os.UserConfigDir()\n\trequire.NoError(t, err)\n\tpath, err := defaultRulesPath()\n\trequire.NoError(t, err)\n\tassert.Equal(t, filepath.Join(configDir, \"pikpakcli\", \"rules\", \"rubbish_rules.txt\"), path)\n}\n\nfunc TestResolveRulesPathForDirectory(t *testing.T) {\n\tdir := t.TempDir()\n\n\tpath, err := resolveRulesPath(dir)\n\trequire.NoError(t, err)\n\tassert.Equal(t, filepath.Join(dir, \"rubbish_rules.txt\"), path)\n}\n\nfunc TestDownloadDefaultRules(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t_, _ = w.Write([]byte(\".DS_Store\\n*.tmp\\n\"))\n\t}))\n\tdefer server.Close()\n\n\ttargetDir := t.TempDir()\n\ttargetPath := filepath.Join(targetDir, \"rules.txt\")\n\terr := downloadDefaultRules(targetPath, server.URL)\n\trequire.NoError(t, err)\n\n\tbs, err := os.ReadFile(targetPath)\n\trequire.NoError(t, err)\n\tassert.Equal(t, \".DS_Store\\n*.tmp\\n\", string(bs))\n}\n\nfunc TestBuildLocalOpenCommand(t *testing.T) {\n\tname, args, err := buildLocalOpenCommand(\"linux\", \"/tmp/rules.txt\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"xdg-open\", name)\n\tassert.Equal(t, []string{\"/tmp/rules.txt\"}, args)\n}\n"
  },
  {
    "path": "cli/share/share.go",
    "content": "package share\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar ShareCommand = &cobra.Command{\n\tUse:     \"share\",\n\tAliases: []string{\"d\"},\n\tShort:   `Share file links on the pikpak server`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\tif len(args) > 0 {\n\t\t\targs, err = api.ExpandRemotePatterns(&p, folder, args, false)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(\"Expand share target failed\")\n\t\t\t\tlogx.Error(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t// Output file handle\n\t\tvar f = os.Stdout\n\t\tif strings.TrimSpace(output) != \"\" {\n\t\t\tfile, err := os.Create(output)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(\"Create file failed\")\n\t\t\t\tlogx.Error(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tf = file\n\t\t}\n\n\t\tif len(args) > 0 {\n\t\t\tshareFiles(&p, args, f)\n\t\t} else {\n\t\t\tshareFolder(&p, f)\n\t\t}\n\t},\n}\n\n// Specifies the folder of the pikpak server\n// default is the root folder\nvar folder string\n\n// Specifies the file to write\n// default is the stdout\nvar output string\n\nvar parentId string\n\nfunc init() {\n\tShareCommand.Flags().StringVarP(&folder, \"path\", \"p\", \"/\", \"specific the folder of the pikpak server\")\n\tShareCommand.Flags().StringVarP(&output, \"output\", \"o\", \"\", \"specific the file to write\")\n\tShareCommand.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"parent folder id\")\n}\n\n// Share folder\nfunc shareFolder(p *api.PikPak, f *os.File) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetDeepFolderId(\"\", folder)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\tfileStat, err := p.GetFolderFileStatList(parentId)\n\tif err != nil {\n\t\tfmt.Println(\"Get folder file stat list failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tfor _, stat := range fileStat {\n\t\t// logrus.Debug(stat)\n\t\tif stat.Kind == api.FileKindFile {\n\t\t\tfmt.Fprintf(f, \"PikPak://%s|%s|%s\\n\", stat.Name, stat.Size, stat.Hash)\n\t\t}\n\t}\n}\n\n// Share files\nfunc shareFiles(p *api.PikPak, args []string, f *os.File) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetPathFolderId(folder)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Get parent id failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\tfor _, path := range args {\n\t\tstat, err := resolveShareTarget(p, parentId, path)\n\t\tif err != nil {\n\t\t\tfmt.Println(path, \"get file stat error\")\n\t\t\tlogx.Error(err)\n\t\t\tcontinue\n\t\t}\n\t\tfmt.Fprintf(f, \"PikPak://%s|%s|%s\\n\", stat.Name, stat.Size, stat.Hash)\n\t}\n}\n\nfunc resolveShareTarget(p *api.PikPak, resolvedParentID string, target string) (api.FileStat, error) {\n\tif strings.HasPrefix(target, \"/\") || strings.Contains(target, \"/\") {\n\t\treturn p.GetFileByPath(target)\n\t}\n\treturn p.GetFileStat(resolvedParentID, target)\n}\n"
  },
  {
    "path": "cli/shell.go",
    "content": "package cli\n\nimport (\n\tishell \"github.com/52funny/pikpakcli/internal/shell\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar shellCmd = &cobra.Command{\n\tUse:   \"shell\",\n\tShort: \"Start an interactive PikPak shell\",\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tishell.Start(rootCmd)\n\t},\n}\n"
  },
  {
    "path": "cli/upload/upload.go",
    "content": "package upload\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar UploadCmd = &cobra.Command{\n\tUse:     \"upload\",\n\tAliases: []string{\"u\"},\n\tShort:   `Upload file to pikpak server`,\n\tRun: func(cmd *cobra.Command, args []string) {\n\t\tif len(args) == 0 {\n\t\t\tcmd.Help()\n\t\t\treturn\n\t\t}\n\t\tapi.Concurrent = uploadConcurrency\n\t\tp := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)\n\t\terr := p.Login()\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Login failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t\terr = p.AuthCaptchaToken(\"POST:/drive/v1/files\")\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Auth captcha token failed\")\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\n\t\tgo func() {\n\t\t\tticker := time.NewTicker(time.Second * 7200 * 3 / 4)\n\t\t\tdefer ticker.Stop()\n\t\t\tfor range ticker.C {\n\t\t\t\terr := p.RefreshToken()\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogx.Warn(\"session\", \"refresh token failed:\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t\tfor _, v := range args {\n\t\t\tv = utils.ExpandLocalPath(v)\n\t\t\tstat, err := os.Stat(v)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"Get file %s stat failed\\n\", v)\n\t\t\t\tlogx.Error(err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif stat.IsDir() {\n\t\t\t\thandleUploadFolder(&p, v)\n\t\t\t} else {\n\t\t\t\thandleUploadFile(&p, v)\n\t\t\t}\n\t\t}\n\t},\n}\n\n// Specifies the folder of the pikpak server\nvar uploadFolder string\n\n// Specifies the file to upload\nvar uploadConcurrency int64\n\n// Sync mode\nvar sync bool\n\n// Parent path id\nvar parentId string\n\n// Init upload command\nfunc init() {\n\tUploadCmd.Flags().StringVarP(&uploadFolder, \"path\", \"p\", \"/\", \"specific the folder of the pikpak server\")\n\tUploadCmd.Flags().Int64VarP(&uploadConcurrency, \"concurrency\", \"c\", 1<<4, \"specific the concurrency of the upload\")\n\tUploadCmd.Flags().StringSliceVarP(&exclude, \"exn\", \"e\", []string{}, \"specific the exclude file or folder\")\n\tUploadCmd.Flags().BoolVarP(&sync, \"sync\", \"s\", false, \"sync mode\")\n\tUploadCmd.Flags().StringVarP(&parentId, \"parent-id\", \"P\", \"\", \"parent folder id\")\n}\n\n// Exclude string list\nvar exclude []string\n\nvar defaultExcludeRegexp []*regexp.Regexp = []*regexp.Regexp{\n\t// exclude the hidden file\n\tregexp.MustCompile(`^\\..+`),\n}\n\n// Dispose the exclude file or folder\nfunc disposeExclude() {\n\tfor _, v := range exclude {\n\t\tdefaultExcludeRegexp = append(defaultExcludeRegexp, regexp.MustCompile(v))\n\t}\n}\n\nfunc handleUploadFile(p *api.PikPak, path string) {\n\tvar err error\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetDeepFolderOrCreateId(\"\", uploadFolder)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Get folder %s id failed\\n\", uploadFolder)\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\terr = p.UploadFile(parentId, path)\n\tif err != nil {\n\t\tfmt.Printf(\"Upload file %s failed\\n\", path)\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tfmt.Printf(\"Upload file %s success!\\n\", path)\n}\n\n// upload files logic\nfunc handleUploadFolder(p *api.PikPak, path string) {\n\tbasePath := filepath.Base(filepath.ToSlash(path))\n\tuploadFilePath, err := utils.GetUploadFilePath(path, defaultExcludeRegexp)\n\tif err != nil {\n\t\tfmt.Println(\"Get upload file path failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\n\tsyncTxt, err := utils.NewSyncTxt(\".pikpaksync.txt\", sync)\n\tif err != nil {\n\t\tfmt.Println(\"Init sync file failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tdefer syncTxt.Close()\n\n\tuploadFilePath = syncTxt.UnSync(uploadFilePath)\n\n\tfmt.Println(\"upload file list:\")\n\tfor _, f := range uploadFilePath {\n\t\tfmt.Println(filepath.Join(basePath, f))\n\t}\n\n\tif parentId == \"\" {\n\t\tparentId, err = p.GetDeepFolderOrCreateId(\"\", uploadFolder)\n\t\tif err != nil {\n\t\t\tfmt.Printf(\"Get folder %s id error\\n\", uploadFolder)\n\t\t\tlogx.Error(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tlogx.Debug(\"upload\", \"upload folder: \", uploadFolder, \" parentId: \", parentId)\n\n\tparentId, err = p.GetDeepFolderOrCreateId(parentId, basePath)\n\tif err != nil {\n\t\tfmt.Printf(\"Get base_upload_path %s id error\\n\", basePath)\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tparentIdMap := make(map[string]string)\n\tfor _, v := range uploadFilePath {\n\t\tif strings.Contains(v, \"/\") || strings.Contains(v, \"\\\\\") {\n\t\t\tvar id string\n\t\t\tbase := filepath.Dir(v)\n\n\t\t\t// Avoid secondary query ids\n\t\t\tif mId, ok := parentIdMap[base]; !ok {\n\t\t\t\tid, err = p.GetDeepFolderOrCreateId(parentId, base)\n\t\t\t\tif err != nil {\n\t\t\t\t\tfmt.Println(\"Get folder id failed\")\n\t\t\t\t\tlogx.Error(err)\n\t\t\t\t}\n\t\t\t\tparentIdMap[base] = id\n\t\t\t} else {\n\t\t\t\tid = mId\n\t\t\t}\n\n\t\t\terr = p.UploadFile(id, filepath.Join(path, v))\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"%s upload failed\\n\", v)\n\t\t\t\tlogx.Error(err)\n\t\t\t}\n\t\t\tsyncTxt.WriteString(v + \"\\n\")\n\t\t\tfmt.Printf(\"%s upload success!\\n\", v)\n\t\t} else {\n\t\t\terr = p.UploadFile(parentId, filepath.Join(path, v))\n\t\t\tif err != nil {\n\t\t\t\tfmt.Printf(\"%s upload failed\\n\", v)\n\t\t\t\tlogx.Error(err)\n\t\t\t}\n\t\t\tsyncTxt.WriteString(v + \"\\n\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "conf/config.go",
    "content": "package conf\n\nimport (\n\t\"bytes\"\n\t\"encoding/binary\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v2\"\n)\n\ntype ConfigType struct {\n\tProxy    string     `yaml:\"proxy\"`\n\tUsername string     `yaml:\"username\"`\n\tPassword string     `yaml:\"password\"`\n\tOpen     OpenConfig `yaml:\"open\"`\n}\n\ntype OpenConfig struct {\n\tDownloadDir string   `yaml:\"download_dir\"`\n\tDefault     []string `yaml:\"default\"`\n\tText        []string `yaml:\"text\"`\n\tImage       []string `yaml:\"image\"`\n\tVideo       []string `yaml:\"video\"`\n\tAudio       []string `yaml:\"audio\"`\n\tPDF         []string `yaml:\"pdf\"`\n}\n\nvar Config ConfigType\n\n// UseProxy returns whether the proxy is used\nfunc (c *ConfigType) UseProxy() bool {\n\treturn len(c.Proxy) != 0\n}\n\n// Initializing configuration information\nfunc InitConfig(path string) error {\n\t// Firstly, read the config info from executable file\n\tif readFromBinary() == nil {\n\t\treturn nil\n\t}\n\n\t// Secondly, it reads config.yml from the given path.\n\t// If there is no config.yml in the given path, it reads it from the default config path.\n\t_, err := os.Stat(path)\n\tswitch os.IsNotExist(err) {\n\tcase true:\n\t\tif err := readFromConfigDir(); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase false:\n\t\tif err := readFromPath(path); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Not empty\n\t// Must contains '://'\n\tif len(Config.Proxy) != 0 && !strings.Contains(Config.Proxy, \"://\") {\n\t\treturn fmt.Errorf(\"proxy should contains ://\")\n\t}\n\treturn nil\n}\n\n// Read config from binary in the end\n// config_bytes: n bytes\n// end_magic: 10 bytes\n// size: 4 bytes\n// -----------------------------------\n// | config_bytes | size | end_magic |\n// -----------------------------------\nfunc readFromBinary() error {\n\tf, err := os.Open(os.Args[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\tstat, err := f.Stat()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar end_magic = make([]byte, 10)\n\tn, err := f.ReadAt(end_magic, stat.Size()-10)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif n != 10 {\n\t\treturn fmt.Errorf(\"read end_magic err: %d\", n)\n\t}\n\n\t// Not have `config.yml` in the end\n\tif !bytes.Equal(end_magic, []byte(\"config.yml\")) {\n\t\treturn fmt.Errorf(\"not a pikpakcli binary\")\n\t}\n\n\tvar size = make([]byte, 4)\n\tn, err = f.ReadAt(size, stat.Size()-14)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif n != 4 {\n\t\treturn fmt.Errorf(\"read size err: %d\", n)\n\t}\n\n\tconfigSize := int64(binary.LittleEndian.Uint32(size))\n\tconfigBuf := make([]byte, configSize)\n\n\tn, err = f.ReadAt(configBuf, stat.Size()-14-configSize)\n\n\tif err != nil || n != int(configSize) {\n\t\treturn err\n\t}\n\n\tif n != int(configSize) {\n\t\treturn fmt.Errorf(\"read config size err: %d\", n)\n\t}\n\n\t// Unmarshal config\n\treturn yaml.Unmarshal(configBuf, &Config)\n}\n\n// Read configuration file from the given path\nfunc readFromPath(path string) error {\n\treturn readConfig(path)\n}\n\n// Read configuration file from config path\nfunc readFromConfigDir() error {\n\tconfigDir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn readConfig(filepath.Join(configDir, \"pikpakcli\", \"config.yml\"))\n}\n\nfunc readConfig(path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\tbs, err := io.ReadAll(f)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = yaml.Unmarshal(bs, &Config)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config_example.yml",
    "content": "# Proxy URL, for example: http://127.0.0.1:7890\nproxy:\n# PikPak account username or phone number with country code\nusername: xxx\n# PikPak account password\npassword: xxx\n# Local open command settings used by the interactive shell builtin `open`\nopen:\n# Local cache directory for files that need to be downloaded before opening\n  download_dir:\n# Fallback command used when no file-type-specific command is configured\n  default: []\n# Command used to open text and source files\n  text: []\n# Command used to open image files\n  image: []\n# Command used to open video files\n  video: []\n# Command used to open audio files\n  audio: []\n# Command used to open PDF files\n  pdf: []\n"
  },
  {
    "path": "docs/command.md",
    "content": "# Command Usage\n\n> For docker users, please refer to the [Docker Command Usage](docs/command_docker.md).\n\n## Upload\n\n- Uploads all files in the local directory to the Movies folder.\n\n  ```bash\n  pikpakcli upload -p Movies .\n  ```\n\n- Upload files in local directory except for `mp3`, `jpg` to Movies folder.\n\n  ```bash\n  pikpakcli upload  -e .mp3,.jpg -p Movies .\n  ```\n\n- Select the number of concurrent tasks for the upload (default is 16).\n\n  ```bash\n  pikpakcli -c 20 -p Movies .\n  ```\n\n- Use the `-P` flag to set the `id` of the folder on the Pikpak cloud.\n\n  ```bash\n  pikpakcli upload -P AgmoDVmJPYbHn8ito1 .\n  ```\n\n- Running `pikpakcli upload` without any local path arguments shows the command help.\n\n## Download\n\n- Download the target pointed to by `-p`. If it is a directory, download it recursively; if it is a file, download that file.\n\n  ```bash\n  pikpakcli download -p Movies\n  pikpakcli download -p Movies/Peppa_Pig.mp4\n  ```\n\n- Use `-p` as the base remote path, then append the following argument to it. The CLI will decide whether the target is a file or a directory.\n\n  ```bash\n  pikpakcli download -p Movies Peppa_Pig.mp4\n  pikpakcli download -p Movies Cartoons\n  pikpakcli download -p Movies Kids/Peppa_Pig.mp4\n  ```\n\n- Use an absolute remote path in the argument to override `-p`.\n\n  ```bash\n  pikpakcli download -p Movies /TV/Peppa_Pig.mp4\n  ```\n\n- Limit the number of files that can be downloaded at the same time (default: 1).\n\n  ```bash\n  pikpakcli download -c 5 -p Movies\n  ```\n\n- Specify the output directory of downloaded files.\n\n  ```bash\n  pikpakcli download -p Movies -o Film\n  ```\n\n- Use the `-g` flag to display status information during the download process.\n\n  ```bash\n  pikpakcli download -p Movies -o Film -g\n  ```\n\n## Share\n\n- Share links to all files under Movies.\n\n  ```bash\n  pikpakcli share -p Movies\n  ```\n\n- Share the link to the specified file.\n\n  ```bash\n  pikpakcli share Movies/Peppa_Pig.mp4\n  ```\n\n- Share link output to a specified file.\n\n  ```bash\n  pikpakcli share  --out sha.txt -p Movies\n  ```\n\n## New\n\n### New Folder\n\n- Create a new folder NewFolder under Movies\n\n  ```bash\n  pikpakcli new folder -p Movies NewFolder\n  ```\n\n### New Sha File\n\n- Create a new Sha file under Movies.\n\n  ```bash\n  pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3'\n  ```\n\n### New Magnet File\n\n- Create new magnet file.\n\n  ```bash\n  pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732'\n  ```\n\n## Quota\n\n- Get space on your PikPak cloud drive.\n\n  ```bash\n  pikpakcli quota -H\n  ```\n\n## Ls\n\n- Get information about all files in the root directory.\n\n  ```bash\n  pikpakcli ls -lH -p /\n  ```\n\n## Delete\n\n- Delete a file by full path from the PikPak cloud.\n\n  ```bash\n  pikpakcli delete /Movies/Peppa_Pig.mp4\n  ```\n\n- Delete entries from a specific directory using the `-p` flag.\n\n  ```bash\n  pikpakcli delete -p /Movies Peppa_Pig.mp4\n  ```\n\n- Delete multiple entries under the same path.\n\n  ```bash\n  pikpakcli delete -p /Movies File1.mp4 File2.mp4\n  ```\n\n## Rubbish\n\n- Scan a directory recursively with the default rubbish rules. If the rule file does not exist in the user config directory, the CLI downloads it from this repository automatically.\n\n  ```bash\n  pikpakcli rubbish\n  pikpakcli rubbish -p /Movies\n  ```\n\n- Preview matched rubbish files without deleting them, then delete them with `-d`.\n\n  ```bash\n  pikpakcli rubbish -p /Movies\n  pikpakcli rubbish -p /Movies -d\n  ```\n\n- Open the local rules file or the local rules directory. If the default rule file is missing, it is downloaded first and then opened.\n\n  ```bash\n  pikpakcli rubbish --open-rules\n  pikpakcli rubbish --open-rules-dir\n  ```\n\n- Download the default rules file explicitly, or use a custom local path or remote URL as the rules source.\n\n  ```bash\n  pikpakcli rubbish --download-rules\n  pikpakcli rubbish --rules ~/.config/pikpakcli/rules/rubbish_rules.txt\n  pikpakcli rubbish --rules https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt\n  ```\n\n## Rename\n\n- Rename a file or folder by full path.\n\n  ```bash\n  pikpakcli rename /Movies/Peppa_Pig.mp4 Peppa_Pig_S01E01.mp4\n  ```\n\n- Rename a folder.\n\n  ```bash\n  pikpakcli rename /Movies/Cartoons Kids\n  ```\n\n## Shell\n\n- Start the interactive shell.\n\n  ```bash\n  pikpakcli shell\n  ```\n\n- Change directory and list files in the current path.\n\n  ```bash\n  pikpakcli shell\n  cd \"/Movies/Kids Cartoons\"\n  ls\n  ```\n\n- Open a remote file from the shell with a local application.\n\n  ```bash\n  pikpakcli shell\n  cd \"/Movies\"\n  open Peppa_Pig.mp4\n  ```\n"
  },
  {
    "path": "docs/command_docker.md",
    "content": "# Docker Command Usage\n\nFor docker users, the most different part is linking the configuration file (i.e., `config.yml`) and folder you want to operate (e.g., `download` or `upload`) into the container.\n\n## Upload\n\n- Uploads all files in the local directory (e.g., `/path/to/upload`) to the `Movies` folder.\n\n  ```bash\n  # original cli: pikpakcli upload -p Movies .\n  # Docker cli\n  docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/upload/:/upload pikpakcli:latest upload -p Movies /upload\n  ```\n\n- Upload files in local directory except for `mp3`, `jpg` to Movies folder.\n\n  ```bash\n  # original cli: pikpakcli upload -e .mp3,.jpg -p Movies .\n  # Docker cli\n  docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/upload/:/upload pikpakcli:latest upload -e .mp3,.jpg -p Movies /upload\n  ```\n\n## Download\n\n- Download the target pointed to by `-p`. The target can be a folder or a file.\n\n```bash\n  # original cli: pikpakcli download -p Movies\n  # Docker cli\n  # the option -o is used to specify the folder in container to save downloaded files\n  docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies -o /download\n  ```\n\n- Use `-p` as the base remote path, then append the argument to it. The CLI will decide whether it is a file or a folder.\n\n```bash\n  # original cli: pikpakcli download -p Movies Peppa_Pig.mp4\n  # Docker cli\n  docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies Peppa_Pig.mp4 -o /download \n  ```\n\n- Use an absolute remote path in the argument to override `-p`.\n\n```bash\n  # original cli: pikpakcli download -p Movies /TV/Peppa_Pig.mp4\n  # Docker cli\n  docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies /TV/Peppa_Pig.mp4 -o /download\n  ```\n\n\n> Other download commands are omitted here, please refer to the original cli commands in [Command Usage](docs/command.md).\n\n\n## Wrapper Script\n\nWe provide a wrapper script `docker_cli.sh` to simplify the docker command usage. You can run the script directly after setting up the `config.yml` file in the current directory. The script will create two folders `pikpak_downloads` and `pikpak_uploads` in the current directory for download and upload operations respectively.\n\n```bash\n# Make the script executable\nchmod +x docker_cli.sh\n# Run the script for upload\n./docker_cli.sh upload -p Movies ./pikpak_uploads\n# Run the script for download\n./docker_cli.sh download -p Movies -o ./pikpak_downloads\n```\n"
  },
  {
    "path": "docs/command_zhCN.md",
    "content": "# 命令使用方法\n\n## 上传\n\n- 将本地目录下的所有文件上传至 Movies 文件夹内\n\n  ```bash\n  pikpakcli upload -p Movies .\n  ```\n\n- 将本地目录下除了后缀名为`mp3`, `jpg`的文件上传至 Movies 文件夹内\n\n  ```bash\n  pikpakcli upload  -e .mp3,.jpg -p Movies .\n  ```\n\n- 指定上传的协程数目(默认为 16)\n\n  ```bash\n  pikpakcli -c 20 -p Movies .\n  ```\n\n- 使用 `-P` 标志设置 Pikpak 云上文件夹的 `id`\n\n  ```bash\n  pikpakcli upload -P AgmoDVmJPYbHn8ito1 .\n  ```\n\n- 直接运行 `pikpakcli upload` 且不带任何本地路径参数时，会显示该命令的帮助信息。\n\n## 下载\n\n- 下载 `-p` 指向的目标。如果该目标是文件夹则递归下载，如果是文件则下载该文件\n\n  ```bash\n  pikpakcli download -p Movies\n  pikpakcli download -p Movies/Peppa_Pig.mp4\n  ```\n\n- 把 `-p` 作为远端基路径，再拼接后面的参数。CLI 会自动判断目标是文件还是文件夹\n\n  ```bash\n  pikpakcli download -p Movies Peppa_Pig.mp4\n  pikpakcli download -p Movies Cartoons\n  pikpakcli download -p Movies Kids/Peppa_Pig.mp4\n  ```\n\n- 如果参数本身是绝对路径，则会覆盖 `-p`\n\n  ```bash\n  pikpakcli download -p Movies /TV/Peppa_Pig.mp4\n  ```\n\n- 限制同时下载的文件个数 (默认: 1)\n\n  ```bash\n  pikpakcli download -c 5 -p Movies\n  ```\n\n- 指定下载内容的输出目录\n\n  ```bash\n  pikpakcli download -p Movies -o Film\n  ```\n\n- 使用 `-g` 标志显示下载过程中的状态信息\n  ```bash\n  pikpakcli download -p Movies -o Film -g\n  ```\n\n## 分享\n\n- 分享 Movies 下的所有文件的链接\n\n  ```bash\n  pikpakcli share -p Movies\n  ```\n\n- 分享指定文件的链接\n\n  ```bash\n  pikpakcli share Movies/Peppa_Pig.mp4\n  ```\n\n- 分享链接输出到指定文件\n\n  ```bash\n  pikpakcli share  --out sha.txt -p Movies\n  ```\n\n## 新建\n\n### 新建文件夹\n\n- 在 Movies 下新建文件夹 NewFolder\n\n  ```bash\n  pikpakcli new folder -p Movies NewFolder\n  ```\n\n### 新建 Sha 文件\n\n- 在 Movies 下新建 Sha 文件\n\n  ```bash\n  pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3'\n  ```\n\n### 新建磁力\n\n- 新建磁力文件\n\n  ```bash\n  pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732'\n  ```\n\n## 配额\n\n- 获取 PikPak 云盘的空间\n\n  ```bash\n  pikpakcli quota -H\n  ```\n\n## 获取目录信息\n\n- 获取根目录下面的所有文件信息\n\n  ```bash\n  pikpakcli ls -lH -p /\n  ```\n\n## 删除\n\n- 按完整路径删除文件\n\n  ```bash\n  pikpakcli delete /Movies/Peppa_Pig.mp4\n  ```\n\n- 使用 `-p` 指定父目录后删除其中的文件或文件夹\n\n  ```bash\n  pikpakcli delete -p /Movies Peppa_Pig.mp4\n  ```\n\n- 在同一路径下同时删除多个文件或文件夹\n\n  ```bash\n  pikpakcli delete -p /Movies File1.mp4 File2.mp4\n  ```\n\n## 垃圾文件清理\n\n- 使用默认垃圾文件规则递归扫描目录。如果用户配置目录中还没有规则文件，CLI 会自动从当前仓库下载默认规则。\n\n  ```bash\n  pikpakcli rubbish\n  pikpakcli rubbish -p /Movies\n  ```\n\n- 默认只预览匹配结果，不会删除；加上 `-d` 后才会执行删除。\n\n  ```bash\n  pikpakcli rubbish -p /Movies\n  pikpakcli rubbish -p /Movies -d\n  ```\n\n- 打开本地规则文件或规则目录。如果默认规则文件不存在，会先下载再打开。\n\n  ```bash\n  pikpakcli rubbish --open-rules\n  pikpakcli rubbish --open-rules-dir\n  ```\n\n- 手动下载默认规则文件，或者指定自定义本地路径 / 远程 URL 作为规则来源。\n\n  ```bash\n  pikpakcli rubbish --download-rules\n  pikpakcli rubbish --rules ~/.config/pikpakcli/rules/rubbish_rules.txt\n  pikpakcli rubbish --rules https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt\n  ```\n\n## 重命名\n\n- 按完整路径重命名文件或文件夹\n\n  ```bash\n  pikpakcli rename /Movies/Peppa_Pig.mp4 Peppa_Pig_S01E01.mp4\n  ```\n\n- 重命名文件夹\n\n  ```bash\n  pikpakcli rename /Movies/Cartoons Kids\n  ```\n\n## 交互 Shell\n\n- 启动交互式 shell\n\n  ```bash\n  pikpakcli shell\n  ```\n\n- 在 shell 中切换目录并查看当前目录文件\n\n  ```bash\n  pikpakcli shell\n  cd \"/Movies/Kids Cartoons\"\n  ls\n  ```\n\n- 在 shell 中打开远端文件到本地默认程序\n\n  ```bash\n  pikpakcli shell\n  cd \"/Movies\"\n  open Peppa_Pig.mp4\n  ```\n"
  },
  {
    "path": "docs/config.md",
    "content": "## Configuration\n\nThe CLI reads the following fields from `config.yml`:\n\n```yml\nproxy:\nusername: xxx\npassword: xxx\nopen:\n  download_dir:\n  default: []\n  text: []\n  image: []\n  video: []\n  audio: []\n  pdf: []\n```\n\n### Basic Fields\n\n- `username`: your PikPak account username or phone number with country code such as `+861xxxxxxxxxx`.\n- `password`: your PikPak account password.\n- `proxy`: optional proxy URL such as `http://127.0.0.1:7890`.\n\n> `proxy` must contain `://`.\n\n### Open Settings\n\nThe `open` section is used by the interactive shell builtin `open`.\n\n- `download_dir`: optional local cache directory for files that must be downloaded before opening.\n- `default`: fallback local command used when no file-type-specific command is configured.\n- `text`: local command used to open text and source files.\n- `image`: local command used to open image files.\n- `video`: local command used to open video files.\n- `audio`: local command used to open audio files.\n- `pdf`: local command used to open PDF files.\n\nEach command field is a YAML string array. The first item is the executable name and the remaining items are its arguments.\n\nIf the command array contains `{path}`, it will be replaced with the local file path or remote media URL. If `{path}` is not present, the path or URL is appended to the end of the command automatically.\n\nFor video files, the shell `open` command prefers opening a remote media URL directly. Other file types are downloaded to the local cache directory before opening.\n\n### Default Open Behavior\n\nIf the `open` section is not configured, the builtin `open` uses platform defaults:\n\n- macOS: `text -> TextEdit`, `image/pdf -> Preview`, `video/audio -> IINA`, others -> `open`\n- Linux: `xdg-open`\n- Windows: `cmd /c start`\n\n### Example\n\n```yml\nproxy: http://127.0.0.1:7890\nusername: +861xxxxxxxxxx\npassword: your-password\nopen:\n  download_dir: ~/Downloads/pikpak-open\n  default: [\"open\"]\n  text: [\"zed\"]\n  image: [\"open\", \"-a\", \"Preview\"]\n  video: [\"open\", \"-a\", \"IINA\"]\n  audio: [\"open\", \"-a\", \"IINA\"]\n  pdf: [\"open\", \"-a\", \"Preview\"]\n```\n"
  },
  {
    "path": "docs/config_zhCN.md",
    "content": "## 配置说明\n\nCLI 会从 `config.yml` 中读取以下字段：\n\n```yml\nproxy:\nusername: xxx\npassword: xxx\nopen:\n  download_dir:\n  default: []\n  text: []\n  image: []\n  video: []\n  audio: []\n  pdf: []\n```\n\n### 基础字段\n\n- `username`：你的 PikPak 账号用户名，或者带区号的手机号，例如 `+861xxxxxxxxxx`。\n- `password`：你的 PikPak 账号密码。\n- `proxy`：可选代理地址，例如 `http://127.0.0.1:7890`。\n\n> `proxy` 必须包含 `://`。\n\n### Open 配置\n\n`open` 配置段用于交互式 shell 中的内置 `open` 命令。\n\n- `download_dir`：可选的本地缓存目录，用于存放打开前需要先下载的文件。\n- `default`：当没有匹配到具体文件类型配置时使用的兜底本地命令。\n- `text`：用于打开文本文件和源码文件的本地命令。\n- `image`：用于打开图片文件的本地命令。\n- `video`：用于打开视频文件的本地命令。\n- `audio`：用于打开音频文件的本地命令。\n- `pdf`：用于打开 PDF 文件的本地命令。\n\n每个命令字段都使用 YAML 字符串数组。第一个元素是可执行程序名，后面的元素是它的参数。\n\n如果命令数组中包含 `{path}`，运行时会将它替换为本地文件路径或远端媒体 URL。如果没有写 `{path}`，程序会自动把路径或 URL 追加到命令末尾。\n\n对于视频文件，shell 中的 `open` 命令会优先直接打开远端媒体 URL。其他文件类型会先下载到本地缓存目录，再调用本地程序打开。\n\n### 默认打开行为\n\n如果没有配置 `open`，内置 `open` 会使用各平台默认行为：\n\n- macOS：`text -> TextEdit`，`image/pdf -> Preview`，`video/audio -> IINA`，其他类型 -> `open`\n- Linux：`xdg-open`\n- Windows：`cmd /c start`\n\n### 示例\n\n```yml\nproxy: http://127.0.0.1:7890\nusername: +861xxxxxxxxxx\npassword: your-password\nopen:\n  download_dir: ~/Downloads/pikpak-open\n  default: [\"open\"]\n  text: [\"zed\"]\n  image: [\"open\", \"-a\", \"Preview\"]\n  video: [\"open\", \"-a\", \"IINA\"]\n  audio: [\"open\", \"-a\", \"IINA\"]\n  pdf: [\"open\", \"-a\", \"Preview\"]\n```\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/52funny/pikpakcli\n\ngo 1.21.3\n\nrequire (\n\tgithub.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c\n\tgithub.com/chzyer/readline v1.5.1\n\tgithub.com/fatih/color v1.15.0\n\tgithub.com/json-iterator/go v1.1.12\n\tgithub.com/spf13/cobra v1.6.1\n\tgithub.com/spf13/pflag v1.0.5\n\tgithub.com/stretchr/testify v1.8.4\n\tgithub.com/tidwall/gjson v1.14.4\n\tgithub.com/vbauerster/mpb/v8 v8.7.2\n\tgopkg.in/yaml.v2 v2.4.0\n)\n\nrequire (\n\tgithub.com/VividCortex/ewma v1.2.0 // indirect\n\tgithub.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.0.1 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.17 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.15 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/rivo/uniseg v0.4.4 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.0 // indirect\n\tgolang.org/x/sys v0.16.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c h1:ecJG8tmvgH6exVE4+I3rFPPA1Mk3/lNb8VZ6A7dtcyI=\ngithub.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c/go.mod h1:YA/IS8XUrMTcrY+J4yOJ3CDgoyQ28NOOo4GnzOL6bTI=\ngithub.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=\ngithub.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=\ngithub.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=\ngithub.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=\ngithub.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=\ngithub.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=\ngithub.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=\ngithub.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=\ngithub.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=\ngithub.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=\ngithub.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=\ngithub.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=\ngithub.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=\ngithub.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=\ngithub.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=\ngithub.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=\ngithub.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14=\ngithub.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY=\ngolang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=\ngolang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/api/captcha_token.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nconst package_name = `com.pikcloud.pikpak`\nconst client_version = `1.21.0`\nconst md5_obj = `[{\"alg\":\"md5\",\"salt\":\"\"},{\"alg\":\"md5\",\"salt\":\"E32cSkYXC2bciKJGxRsE8ZgwmH\\/YwkvpD6\\/O9guSOa2irCwciH4xPHaH\"},{\"alg\":\"md5\",\"salt\":\"QtqgfMgHP2TFl\"},{\"alg\":\"md5\",\"salt\":\"zOKgHT56L7nIzFzDpUGhpWFrgP53m3G6ML\"},{\"alg\":\"md5\",\"salt\":\"S\"},{\"alg\":\"md5\",\"salt\":\"THxpsktzfFXizUv7DK1y\\/N7NZ1WhayViluBEvAJJ8bA1Wr6\"},{\"alg\":\"md5\",\"salt\":\"y9PXH3xGUhG\\/zQI8CaapRw2LhldCaFM9CRlKpZXJvj+pifu\"},{\"alg\":\"md5\",\"salt\":\"+RaaG7T8FRTI4cP019N5y9ofLyHE9ySFUr\"},{\"alg\":\"md5\",\"salt\":\"6Pf1l8UTeuzYldGtb\\/d\"}]`\n\ntype md5Obj struct {\n\tAlg  string `json:\"alg\"`\n\tSalt string `json:\"salt\"`\n}\n\nvar md5Arr []md5Obj\n\nfunc init() {\n\terr := jsoniter.Unmarshal([]byte(md5_obj), &md5Arr)\n\tif err != nil {\n\t\tlogx.Warn(\"api\", err)\n\t}\n}\n\nfunc (p *PikPak) AuthCaptchaToken(action string) error {\n\tm := make(map[string]interface{})\n\tm[\"action\"] = action\n\tm[\"captcha_token\"] = p.CaptchaToken\n\tm[\"client_id\"] = clientID\n\tm[\"device_id\"] = p.DeviceId\n\tts := fmt.Sprintf(\"%d\", time.Now().UnixMilli())\n\tstr := clientID + client_version + package_name + p.DeviceId + ts\n\n\tfor i := 0; i < len(md5Arr); i++ {\n\t\talg := md5Arr[i].Alg\n\t\tsalt := md5Arr[i].Salt\n\t\tif alg == \"md5\" {\n\t\t\tstr = fmt.Sprintf(\"%x\", md5.Sum([]byte(str+salt)))\n\t\t}\n\t}\n\t// logrus.Debug(\"captcha_sign: \", \"1.\"+str)\n\tm[\"meta\"] = map[string]string{\n\t\t\"captcha_sign\":   \"1.\" + str,\n\t\t\"user_id\":        p.Sub,\n\t\t\"package_name\":   package_name,\n\t\t\"client_version\": client_version,\n\t\t\"timestamp\":      ts,\n\t}\n\tm[\"redirect_uri\"] = \"ttps://api.mypikpak.com/v1/auth/callback\"\n\tbs, err := jsoniter.Marshal(m)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := p.newRequest(\"POST\", \"https://user.mypikpak.com/v1/shield/captcha/init?client_id=\"+clientID, bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\tif error_code != 0 {\n\t\treturn fmt.Errorf(\"auth captcha token error: %s\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\tp.CaptchaToken = jsoniter.Get(bs, \"captcha_token\").ToString()\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/constants.go",
    "content": "package api\n\nconst (\n\tFileKindFolder = \"drive#folder\"\n\tFileKindFile   = \"drive#file\"\n)\n"
  },
  {
    "path": "internal/api/download.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/vbauerster/mpb/v8\"\n)\n\nconst maxDownloadRetries = 3\n\nvar errRestartDownload = errors.New(\"restart download from beginning\")\n\ntype retryableDownloadError struct {\n\terr error\n}\n\nfunc (f *File) requestContext() context.Context {\n\tif f != nil && f.ctx != nil {\n\t\treturn f.ctx\n\t}\n\treturn context.Background()\n}\n\nfunc (e *retryableDownloadError) Error() string {\n\treturn e.err.Error()\n}\n\nfunc (e *retryableDownloadError) Unwrap() error {\n\treturn e.err\n}\n\nfunc retryableDownload(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn &retryableDownloadError{err: err}\n}\n\nfunc isRetryableDownloadError(err error) bool {\n\tvar target *retryableDownloadError\n\treturn errors.As(err, &target)\n}\n\n// Download file\nfunc (f *File) Download(path string, bar *mpb.Bar) error {\n\texpectedSize, err := strconv.ParseInt(f.Size, 10, 64)\n\tif err != nil {\n\t\texpectedSize = -1\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxDownloadRetries; attempt++ {\n\t\tlastErr = f.download(path, bar, expectedSize)\n\t\tif lastErr == nil {\n\t\t\treturn nil\n\t\t}\n\t\tif !isRetryableDownloadError(lastErr) {\n\t\t\treturn lastErr\n\t\t}\n\t\tif attempt == maxDownloadRetries-1 {\n\t\t\tbreak\n\t\t}\n\t\tlogx.Warnf(\"transfer\", \"Download %s interrupted, retrying (%d/%d): %v\", f.Name, attempt+1, maxDownloadRetries-1, lastErr)\n\t}\n\n\treturn lastErr\n}\n\nfunc (f *File) download(path string, bar *mpb.Bar, expectedSize int64) error {\n\toutFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer outFile.Close()\n\n\tinfo, err := outFile.Stat()\n\tif err != nil {\n\t\treturn err\n\t}\n\toffset := info.Size()\n\n\tif expectedSize >= 0 && offset > expectedSize {\n\t\tif err := outFile.Truncate(0); err != nil {\n\t\t\treturn err\n\t\t}\n\t\toffset = 0\n\t}\n\n\tif _, err := outFile.Seek(offset, io.SeekStart); err != nil {\n\t\treturn err\n\t}\n\n\treq, err := http.NewRequestWithContext(f.requestContext(), \"GET\", f.Links.ApplicationOctetStream.URL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"User-Agent\", userAgent)\n\tif offset > 0 {\n\t\treq.Header.Set(\"Range\", fmt.Sprintf(\"bytes=%d-\", offset))\n\t\tif bar != nil {\n\t\t\tbar.SetCurrent(offset)\n\t\t}\n\t}\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn retryableDownload(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tswitch {\n\tcase offset > 0 && resp.StatusCode == http.StatusRequestedRangeNotSatisfiable:\n\t\tif expectedSize >= 0 && offset == expectedSize {\n\t\t\tif bar != nil {\n\t\t\t\tbar.SetCurrent(expectedSize)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif err := outFile.Truncate(0); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif bar != nil {\n\t\t\tbar.SetCurrent(0)\n\t\t}\n\t\treturn retryableDownload(errRestartDownload)\n\tcase offset > 0 && resp.StatusCode == http.StatusOK:\n\t\tlogx.Warnf(\"transfer\", \"Resume file %s failed: server ignored range request, restarting from the beginning\", f.Name)\n\t\tif err := outFile.Truncate(0); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif bar != nil {\n\t\t\tbar.SetCurrent(0)\n\t\t}\n\t\treturn retryableDownload(errRestartDownload)\n\tcase offset > 0 && resp.StatusCode != http.StatusPartialContent:\n\t\tif resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusTooManyRequests {\n\t\t\treturn retryableDownload(fmt.Errorf(\"download request failed: %s\", resp.Status))\n\t\t}\n\t\treturn fmt.Errorf(\"download request failed: %s\", resp.Status)\n\tcase offset == 0 && (resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices):\n\t\tif resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusTooManyRequests {\n\t\t\treturn retryableDownload(fmt.Errorf(\"download request failed: %s\", resp.Status))\n\t\t}\n\t\treturn fmt.Errorf(\"download request failed: %s\", resp.Status)\n\t}\n\n\tvar reader io.ReadCloser\n\tif bar != nil {\n\t\treader = bar.ProxyReader(resp.Body)\n\t} else {\n\t\treader = resp.Body\n\t}\n\tdefer reader.Close()\n\n\tbuf := make([]byte, 1024*128)\n\twritten, err := io.CopyBuffer(outFile, reader, buf)\n\tif err != nil {\n\t\tvar netErr net.Error\n\t\tif errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {\n\t\t\treturn retryableDownload(err)\n\t\t}\n\t\treturn retryableDownload(err)\n\t}\n\n\tcontentLengthHeader := resp.Header.Get(\"Content-Length\")\n\tif contentLengthHeader != \"\" {\n\t\tcontentLength, err := strconv.ParseInt(contentLengthHeader, 10, 64)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"parse content length failed: %w\", err)\n\t\t}\n\t\tif contentLength != written {\n\t\t\treturn retryableDownload(fmt.Errorf(\"content length not equal to written\"))\n\t\t}\n\t}\n\n\tif expectedSize >= 0 && offset+written != expectedSize {\n\t\treturn retryableDownload(fmt.Errorf(\"download incomplete: got %d of %d bytes\", offset+written, expectedSize))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/download_test.go",
    "content": "package api\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDownloadResumesAfterInterruptedTransfer(t *testing.T) {\n\tcontent := []byte(\"hello world\")\n\tvar requests atomic.Int32\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch requests.Add(1) {\n\t\tcase 1:\n\t\t\trequire.Empty(t, r.Header.Get(\"Range\"))\n\t\t\thj, ok := w.(http.Hijacker)\n\t\t\trequire.True(t, ok)\n\t\t\tconn, rw, err := hj.Hijack()\n\t\t\trequire.NoError(t, err)\n\t\t\tdefer conn.Close()\n\n\t\t\t_, err = fmt.Fprintf(rw, \"HTTP/1.1 200 OK\\r\\nContent-Length: %d\\r\\n\\r\\n\", len(content))\n\t\t\trequire.NoError(t, err)\n\t\t\t_, err = rw.Write(content[:5])\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NoError(t, rw.Flush())\n\t\tcase 2:\n\t\t\trequire.Equal(t, \"bytes=5-\", r.Header.Get(\"Range\"))\n\t\t\tremaining := content[5:]\n\t\t\tw.Header().Set(\"Content-Length\", strconv.Itoa(len(remaining)))\n\t\t\tw.Header().Set(\"Content-Range\", fmt.Sprintf(\"bytes 5-%d/%d\", len(content)-1, len(content)))\n\t\t\tw.WriteHeader(http.StatusPartialContent)\n\t\t\t_, err := w.Write(remaining)\n\t\t\trequire.NoError(t, err)\n\t\tdefault:\n\t\t\tt.Fatalf(\"unexpected request count: %d\", requests.Load())\n\t\t}\n\t}))\n\tdefer server.Close()\n\n\tfile := File{\n\t\tFileStat: FileStat{\n\t\t\tName: \"resume.bin\",\n\t\t\tSize: strconv.Itoa(len(content)),\n\t\t},\n\t}\n\tfile.Links.ApplicationOctetStream.URL = server.URL\n\n\ttarget := filepath.Join(t.TempDir(), file.Name)\n\trequire.NoError(t, file.Download(target, nil))\n\n\tdownloaded, err := os.ReadFile(target)\n\trequire.NoError(t, err)\n\trequire.Equal(t, content, downloaded)\n\trequire.EqualValues(t, 2, requests.Load())\n}\n\nfunc TestDownloadRestartsWhenServerIgnoresRangeRequest(t *testing.T) {\n\tcontent := []byte(\"hello world\")\n\tvar requests atomic.Int32\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch requests.Add(1) {\n\t\tcase 1:\n\t\t\trequire.Equal(t, \"bytes=5-\", r.Header.Get(\"Range\"))\n\t\tcase 2:\n\t\t\trequire.Empty(t, r.Header.Get(\"Range\"))\n\t\tdefault:\n\t\t\tt.Fatalf(\"unexpected request count: %d\", requests.Load())\n\t\t}\n\n\t\tw.Header().Set(\"Content-Length\", strconv.Itoa(len(content)))\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, err := w.Write(content)\n\t\trequire.NoError(t, err)\n\t}))\n\tdefer server.Close()\n\n\tfile := File{\n\t\tFileStat: FileStat{\n\t\t\tName: \"restart.bin\",\n\t\t\tSize: strconv.Itoa(len(content)),\n\t\t},\n\t}\n\tfile.Links.ApplicationOctetStream.URL = server.URL\n\n\ttarget := filepath.Join(t.TempDir(), file.Name)\n\trequire.NoError(t, os.WriteFile(target, content[:5], 0644))\n\n\trequire.NoError(t, file.Download(target, nil))\n\n\tdownloaded, err := os.ReadFile(target)\n\trequire.NoError(t, err)\n\trequire.Equal(t, content, downloaded)\n\trequire.EqualValues(t, 2, requests.Load())\n}\n\nfunc TestDownloadTreatsSatisfiedRangeAsSuccess(t *testing.T) {\n\tcontent := []byte(\"hello world\")\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, \"bytes=11-\", r.Header.Get(\"Range\"))\n\t\tw.WriteHeader(http.StatusRequestedRangeNotSatisfiable)\n\t}))\n\tdefer server.Close()\n\n\tfile := File{\n\t\tFileStat: FileStat{\n\t\t\tName: \"complete.bin\",\n\t\t\tSize: strconv.Itoa(len(content)),\n\t\t},\n\t}\n\tfile.Links.ApplicationOctetStream.URL = server.URL\n\n\ttarget := filepath.Join(t.TempDir(), file.Name)\n\trequire.NoError(t, os.WriteFile(target, content, 0644))\n\n\trequire.NoError(t, file.Download(target, nil))\n\n\tf, err := os.Open(target)\n\trequire.NoError(t, err)\n\tdefer f.Close()\n\n\treader := bufio.NewReader(f)\n\tgot, err := reader.Peek(len(content))\n\trequire.NoError(t, err)\n\trequire.Equal(t, content, got)\n}\n"
  },
  {
    "path": "internal/api/file.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\t\"unsafe\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/tidwall/gjson\"\n)\n\ntype FileStat struct {\n\tKind          string    `json:\"kind\"`\n\tID            string    `json:\"id\"`\n\tParentID      string    `json:\"parent_id\"`\n\tName          string    `json:\"name\"`\n\tUserID        string    `json:\"user_id\"`\n\tSize          string    `json:\"size\"`\n\tFileExtension string    `json:\"file_extension\"`\n\tMimeType      string    `json:\"mime_type\"`\n\tCreatedTime   time.Time `json:\"created_time\"`\n\tModifiedTime  time.Time `json:\"modified_time\"`\n\tIconLink      string    `json:\"icon_link\"`\n\tThumbnailLink string    `json:\"thumbnail_link\"`\n\tMd5Checksum   string    `json:\"md5_checksum\"`\n\tHash          string    `json:\"hash\"`\n\tPhase         string    `json:\"phase\"`\n}\ntype File struct {\n\tFileStat\n\tRevision       string `json:\"revision\"`\n\tStarred        bool   `json:\"starred\"`\n\tWebContentLink string `json:\"web_content_link\"`\n\tLinks          struct {\n\t\tApplicationOctetStream struct {\n\t\t\tURL    string    `json:\"url\"`\n\t\t\tToken  string    `json:\"token\"`\n\t\t\tExpire time.Time `json:\"expire\"`\n\t\t} `json:\"application/octet-stream\"`\n\t} `json:\"links\"`\n\tAudit struct {\n\t\tStatus  string `json:\"status\"`\n\t\tMessage string `json:\"message\"`\n\t\tTitle   string `json:\"title\"`\n\t} `json:\"audit\"`\n\tMedias []struct {\n\t\tMediaID   string      `json:\"media_id\"`\n\t\tMediaName string      `json:\"media_name\"`\n\t\tVideo     interface{} `json:\"video\"`\n\t\tLink      struct {\n\t\t\tURL    string    `json:\"url\"`\n\t\t\tToken  string    `json:\"token\"`\n\t\t\tExpire time.Time `json:\"expire\"`\n\t\t} `json:\"link\"`\n\t\tNeedMoreQuota  bool          `json:\"need_more_quota\"`\n\t\tVipTypes       []interface{} `json:\"vip_types\"`\n\t\tRedirectLink   string        `json:\"redirect_link\"`\n\t\tIconLink       string        `json:\"icon_link\"`\n\t\tIsDefault      bool          `json:\"is_default\"`\n\t\tPriority       int           `json:\"priority\"`\n\t\tIsOrigin       bool          `json:\"is_origin\"`\n\t\tResolutionName string        `json:\"resolution_name\"`\n\t\tIsVisible      bool          `json:\"is_visible\"`\n\t\tCategory       string        `json:\"category\"`\n\t} `json:\"medias\"`\n\tTrashed     bool   `json:\"trashed\"`\n\tDeleteTime  string `json:\"delete_time\"`\n\tOriginalURL string `json:\"original_url\"`\n\tParams      struct {\n\t\tPlatform     string `json:\"platform\"`\n\t\tPlatformIcon string `json:\"platform_icon\"`\n\t} `json:\"params\"`\n\tOriginalFileIndex int           `json:\"original_file_index\"`\n\tSpace             string        `json:\"space\"`\n\tApps              []interface{} `json:\"apps\"`\n\tWritable          bool          `json:\"writable\"`\n\tFolderType        string        `json:\"folder_type\"`\n\tCollection        interface{}   `json:\"collection\"`\n\tctx               context.Context\n}\n\ntype fileListResult struct {\n\tNextPageToken string     `json:\"next_page_token\"`\n\tFiles         []FileStat `json:\"files\"`\n}\n\nconst maxListRetries = 3\n\nfunc (p *PikPak) GetFolderFileStatList(parentId string) ([]FileStat, error) {\n\tfilters := `{\"trashed\":{\"eq\":false}}`\n\tquery := url.Values{}\n\tquery.Add(\"thumbnail_size\", \"SIZE_MEDIUM\")\n\tquery.Add(\"limit\", \"500\")\n\tquery.Add(\"parent_id\", parentId)\n\tquery.Add(\"with_audit\", \"false\")\n\tquery.Add(\"filters\", filters)\n\tfileList := make([]FileStat, 0)\n\n\tfor {\n\t\tbs, err := p.getFolderFileStatPage(query)\n\t\tif err != nil {\n\t\t\treturn fileList, err\n\t\t}\n\t\terror_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), \"error_code\").Int()\n\t\tif error_code == 9 {\n\t\t\terr = p.AuthCaptchaToken(\"GET:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn fileList, err\n\t\t\t}\n\t\t}\n\t\tvar result fileListResult\n\t\terr = json.Unmarshal(bs, &result)\n\t\tif err != nil {\n\t\t\treturn fileList, err\n\t\t}\n\t\tfileList = append(fileList, result.Files...)\n\t\tif result.NextPageToken == \"\" {\n\t\t\tbreak\n\t\t}\n\t\tquery.Set(\"page_token\", result.NextPageToken)\n\t}\n\treturn fileList, nil\n}\n\nfunc (p *PikPak) getFolderFileStatPage(query url.Values) ([]byte, error) {\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxListRetries; attempt++ {\n\t\treq, err := p.newRequest(\"GET\", \"https://api-drive.mypikpak.com/drive/v1/files?\"+query.Encode(), nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tbs, err := p.sendRequest(req)\n\t\tif err == nil {\n\t\t\treturn bs, nil\n\t\t}\n\t\tlastErr = err\n\t\tif !isRetryableListError(err) || attempt == maxListRetries-1 {\n\t\t\tbreak\n\t\t}\n\t\tlogx.Warnf(\"transfer\", \"List folder interrupted, retrying (%d/%d): %v\", attempt+1, maxListRetries-1, err)\n\t\ttime.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)\n\t}\n\treturn nil, lastErr\n}\n\nfunc isRetryableListError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, io.ErrUnexpectedEOF) {\n\t\treturn true\n\t}\n\n\tvar netErr net.Error\n\tif errors.As(err, &netErr) {\n\t\treturn true\n\t}\n\n\tmessage := strings.ToLower(err.Error())\n\treturn strings.Contains(message, \"unexpected eof\") ||\n\t\tstrings.Contains(message, \"connection reset by peer\") ||\n\t\tstrings.Contains(message, \"connection closed\") ||\n\t\tstrings.Contains(message, \"broken pipe\")\n}\n\n// Find FileState similar to name in the parentId directory\nfunc (p *PikPak) GetFileStat(parentId string, name string) (FileStat, error) {\n\tstats, err := p.GetFolderFileStatList(parentId)\n\tif err != nil {\n\t\treturn FileStat{}, err\n\t}\n\tfor _, stat := range stats {\n\t\tif stat.Name == name {\n\t\t\treturn stat, nil\n\t\t}\n\t}\n\treturn FileStat{}, errors.New(\"file not found\")\n}\n\nfunc (p *PikPak) GetFileByPath(path string) (FileStat, error) {\n\tparentPath, name := utils.SplitRemotePath(path)\n\tif name == \"\" {\n\t\treturn FileStat{}, errors.New(\"cannot get info of root directory\")\n\t}\n\n\tparentID := \"\"\n\tvar err error\n\tif parentPath != \"\" {\n\t\tparentID, err = p.GetPathFolderId(parentPath)\n\t\tif err != nil {\n\t\t\treturn FileStat{}, err\n\t\t}\n\t}\n\n\treturn p.GetFileStat(parentID, name)\n}\n\nfunc (p *PikPak) GetFile(fileId string) (File, error) {\n\tvar fileInfo File\n\tquery := url.Values{}\n\tquery.Add(\"thumbnail_size\", \"SIZE_MEDIUM\")\n\treq, err := p.newRequest(\"GET\", \"https://api-drive.mypikpak.com/drive/v1/files/\"+fileId+\"?\"+query.Encode(), nil)\n\tif err != nil {\n\t\treturn fileInfo, err\n\t}\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Device-Id\", p.DeviceId)\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn fileInfo, err\n\t}\n\n\terror_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), \"error_code\").Int()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr = p.AuthCaptchaToken(\"GET:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn fileInfo, err\n\t\t\t}\n\t\t}\n\t\terr = errors.New(gjson.Get(*(*string)(unsafe.Pointer(&bs)), \"error\").String() + \":\" + fileId)\n\t\treturn fileInfo, err\n\t}\n\terr = json.Unmarshal(bs, &fileInfo)\n\tif err != nil {\n\t\treturn fileInfo, err\n\t}\n\tfileInfo.ctx = p.requestContext()\n\treturn fileInfo, err\n}\n\nfunc (p *PikPak) DeleteFile(fileId string) error {\nSTART:\n\treq, err := p.newRequest(\"DELETE\", \"https://api-drive.mypikpak.com/drive/v1/files/\"+fileId, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Device-Id\", p.DeviceId)\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := gjson.GetBytes(bs, \"error_code\").Int()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr = p.AuthCaptchaToken(\"DELETE:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn fmt.Errorf(\"%s: %s\", gjson.GetBytes(bs, \"error\").String(), fileId)\n\t}\n\treturn nil\n}\n\nfunc (p *PikPak) Rename(fileId string, newName string) error {\n\tif newName == \"\" {\n\t\treturn errors.New(\"new name cannot be empty\")\n\t}\n\n\tapiURL := \"https://api-drive.mypikpak.com/drive/v1/files/\" + fileId\n\tbody := map[string]string{\"name\": newName}\n\tjsonBody, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn err\n\t}\n\nSTART:\n\treq, err := p.newRequest(\"PATCH\", apiURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Device-Id\", p.DeviceId)\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terrorCode := gjson.GetBytes(bs, \"error_code\").Int()\n\tif errorCode != 0 {\n\t\tif errorCode == 9 {\n\t\t\terr = p.AuthCaptchaToken(\"PATCH:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn fmt.Errorf(\"%s: %s\", gjson.GetBytes(bs, \"error\").String(), fileId)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/file_test.go",
    "content": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype fakeNetError struct{}\n\nfunc (fakeNetError) Error() string   { return \"i/o timeout\" }\nfunc (fakeNetError) Timeout() bool   { return true }\nfunc (fakeNetError) Temporary() bool { return true }\n\nfunc TestIsRetryableListError(t *testing.T) {\n\tassert.True(t, isRetryableListError(io.ErrUnexpectedEOF))\n\tassert.True(t, isRetryableListError(errors.New(\"unexpected EOF\")))\n\tassert.True(t, isRetryableListError(fakeNetError{}))\n\tassert.True(t, isRetryableListError(errors.New(\"read: connection reset by peer\")))\n\tassert.False(t, isRetryableListError(errors.New(\"permission denied\")))\n\tassert.False(t, isRetryableListError(nil))\n}\n\nfunc TestFakeNetErrorImplementsNetError(t *testing.T) {\n\tvar err net.Error = fakeNetError{}\n\tassert.True(t, err.Timeout())\n\tassert.True(t, err.Temporary())\n}\n\nfunc TestPikPakWithContext(t *testing.T) {\n\tbase := NewPikPak(\"user\", \"pass\")\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tderived := base.WithContext(ctx)\n\n\tassert.NotNil(t, derived)\n\tassert.NotSame(t, &base, derived)\n\tassert.Equal(t, ctx, derived.requestContext())\n\tassert.NotEqual(t, ctx, base.requestContext())\n}\n"
  },
  {
    "path": "internal/api/folder.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/tidwall/gjson\"\n)\n\n// 获取文件夹 id\n// dir 可以包括 /.\n// 若以 / 开头，函数会去除 /， 且会从 parent 目录开始查找\nfunc (p *PikPak) GetDeepFolderId(parentId string, dirPath string) (string, error) {\n\tdirPath = utils.Slash(dirPath)\n\tif dirPath == \"\" {\n\t\treturn parentId, nil\n\t}\n\n\tdirS := utils.SplitSeparator(dirPath)\n\n\tfor _, dir := range dirS {\n\t\tid, err := p.GetFolderId(parentId, dir)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tparentId = id\n\t}\n\treturn parentId, nil\n}\n\nfunc (p *PikPak) GetPathFolderId(dirPath string) (string, error) {\n\treturn p.GetDeepFolderId(\"\", dirPath)\n}\n\n// 获取文件夹 id\n// dir 不能包括 /\nfunc (p *PikPak) GetFolderId(parentId string, dir string) (string, error) {\n\t// slash the dir path\n\tdir = utils.Slash(dir)\n\n\tvalue := url.Values{}\n\tvalue.Add(\"parent_id\", parentId)\n\tvalue.Add(\"page_token\", \"\")\n\tvalue.Add(\"with_audit\", \"false\")\n\tvalue.Add(\"thumbnail_size\", \"SIZE_LARGE\")\n\tvalue.Add(\"limit\", \"500\")\n\tfor {\n\t\treq, err := p.newRequest(\"GET\", fmt.Sprintf(\"https://api-drive.mypikpak.com/drive/v1/files?\"+value.Encode()), nil)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treq.Header.Set(\"Country\", \"CN\")\n\t\treq.Header.Set(\"X-Peer-Id\", p.DeviceId)\n\t\treq.Header.Set(\"X-User-Region\", \"1\")\n\t\treq.Header.Set(\"X-Alt-Capability\", \"3\")\n\t\treq.Header.Set(\"X-Client-Version-Code\", \"10083\")\n\t\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\t\tbs, err := p.sendRequest(req)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tfiles := gjson.GetBytes(bs, \"files\").Array()\n\n\t\tfor _, file := range files {\n\t\t\tkind := file.Get(\"kind\").String()\n\t\t\tname := file.Get(\"name\").String()\n\t\t\ttrashed := file.Get(\"trashed\").Bool()\n\t\t\tif kind == FileKindFolder && name == dir && !trashed {\n\t\t\t\treturn file.Get(\"id\").String(), nil\n\t\t\t}\n\t\t}\n\t\tnextToken := gjson.GetBytes(bs, \"next_page_token\").String()\n\t\tif nextToken == \"\" {\n\t\t\tbreak\n\t\t}\n\t\tvalue.Set(\"page_token\", nextToken)\n\t}\n\treturn \"\", ErrNotFoundFolder\n}\n\nfunc (p *PikPak) GetDeepFolderOrCreateId(parentId string, dirPath string) (string, error) {\n\tdirPath = utils.Slash(dirPath)\n\tif dirPath == \"\" || dirPath == \".\" {\n\t\treturn parentId, nil\n\t}\n\n\tdirS := utils.SplitSeparator(dirPath)\n\n\tfor _, dir := range dirS {\n\t\tid, err := p.GetFolderId(parentId, dir)\n\t\tif err != nil {\n\t\t\tlogx.Warn(\"api\", \"dir \", err)\n\t\t\tif err == ErrNotFoundFolder {\n\t\t\t\tcreateId, err := p.CreateFolder(parentId, dir)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn \"\", err\n\t\t\t\t} else {\n\t\t\t\t\tlogx.Debug(\"api\", \"create dir: \", dir)\n\t\t\t\t\tparentId = createId\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t} else {\n\t\t\tparentId = id\n\t\t}\n\t}\n\treturn parentId, nil\n}\n\n// Create new folder in parent folder\n// parentId is parent folder id\nfunc (p *PikPak) CreateFolder(parentId, dir string) (string, error) {\n\tm := map[string]interface{}{\n\t\t\"kind\":      FileKindFolder,\n\t\t\"parent_id\": parentId,\n\t\t\"name\":      dir,\n\t}\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\nSTART:\n\treq, err := p.newRequest(\"POST\", \"https://api-drive.mypikpak.com/drive/v1/files\", bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\treq.Header.Set(\"Product_flavor_name\", \"cha\")\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Client-Version-Code\", \"10083\")\n\treq.Header.Set(\"X-Peer-Id\", p.DeviceId)\n\treq.Header.Set(\"X-User-Region\", \"1\")\n\treq.Header.Set(\"X-Alt-Capability\", \"3\")\n\treq.Header.Set(\"Country\", \"CN\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\terror_code := gjson.GetBytes(bs, \"error_code\").Int()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr := p.AuthCaptchaToken(\"POST:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"create folder error: %s\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\tid := gjson.GetBytes(bs, \"file.id\").String()\n\treturn id, nil\n}\n"
  },
  {
    "path": "internal/api/glob.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"strings\"\n)\n\ntype remotePatternProvider interface {\n\tGetPathFolderId(dirPath string) (string, error)\n\tGetFolderFileStatList(parentId string) ([]FileStat, error)\n}\n\nfunc ExpandRemotePatterns(p remotePatternProvider, basePath string, patterns []string, keepRelative bool) ([]string, error) {\n\texpanded := make([]string, 0, len(patterns))\n\tfor _, pattern := range patterns {\n\t\tmatches, err := expandRemotePattern(p, basePath, pattern, keepRelative)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\texpanded = append(expanded, matches...)\n\t}\n\treturn expanded, nil\n}\n\nfunc expandRemotePattern(p remotePatternProvider, basePath string, pattern string, keepRelative bool) ([]string, error) {\n\tif !hasRemoteWildcard(pattern) {\n\t\treturn []string{pattern}, nil\n\t}\n\n\tresolvedPattern := path.Clean(pattern)\n\tif !path.IsAbs(resolvedPattern) {\n\t\tresolvedPattern = path.Clean(path.Join(\"/\", basePath, pattern))\n\t}\n\n\tparentPath := path.Dir(resolvedPattern)\n\tif parentPath == \".\" {\n\t\tparentPath = \"/\"\n\t}\n\n\tparentID := \"\"\n\tvar err error\n\tif parentPath != \"/\" {\n\t\tparentID, err = p.GetPathFolderId(parentPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfiles, err := p.GetFolderFileStatList(parentID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmatches := make([]string, 0)\n\tnamePattern := path.Base(resolvedPattern)\n\tfor _, file := range files {\n\t\tmatched, err := path.Match(namePattern, file.Name)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid wildcard pattern %s: %w\", pattern, err)\n\t\t}\n\t\tif !matched {\n\t\t\tcontinue\n\t\t}\n\n\t\tmatchPath := path.Join(parentPath, file.Name)\n\t\tif keepRelative && !path.IsAbs(pattern) {\n\t\t\tmatches = append(matches, relativeRemotePath(basePath, matchPath))\n\t\t\tcontinue\n\t\t}\n\t\tmatches = append(matches, matchPath)\n\t}\n\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"no matches found for %s\", pattern)\n\t}\n\n\treturn matches, nil\n}\n\nfunc hasRemoteWildcard(value string) bool {\n\treturn strings.ContainsAny(value, \"*?[\")\n}\n\nfunc relativeRemotePath(basePath string, fullPath string) string {\n\tbase := path.Clean(basePath)\n\tfull := path.Clean(fullPath)\n\tif base == \".\" || base == \"\" || base == \"/\" {\n\t\treturn strings.TrimPrefix(full, \"/\")\n\t}\n\tprefix := base + \"/\"\n\tif strings.HasPrefix(full, prefix) {\n\t\treturn strings.TrimPrefix(full, prefix)\n\t}\n\treturn full\n}\n"
  },
  {
    "path": "internal/api/glob_test.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype fakeRemotePatternProvider struct {\n\tgetPathFolderID       func(dirPath string) (string, error)\n\tgetFolderFileStatList func(parentId string) ([]FileStat, error)\n}\n\nfunc (f fakeRemotePatternProvider) GetPathFolderId(dirPath string) (string, error) {\n\treturn f.getPathFolderID(dirPath)\n}\n\nfunc (f fakeRemotePatternProvider) GetFolderFileStatList(parentId string) ([]FileStat, error) {\n\treturn f.getFolderFileStatList(parentId)\n}\n\nfunc TestExpandRemotePatternsReturnsAbsoluteMatches(t *testing.T) {\n\tprovider := fakeRemotePatternProvider{\n\t\tgetPathFolderID: func(dirPath string) (string, error) {\n\t\t\trequire.Equal(t, \"/Movies\", dirPath)\n\t\t\treturn \"movies-id\", nil\n\t\t},\n\t\tgetFolderFileStatList: func(parentId string) ([]FileStat, error) {\n\t\t\trequire.Equal(t, \"movies-id\", parentId)\n\t\t\treturn []FileStat{\n\t\t\t\t{Name: \"a.mp4\"},\n\t\t\t\t{Name: \"b.mp4\"},\n\t\t\t\t{Name: \"note.txt\"},\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tmatches, err := ExpandRemotePatterns(provider, \"/Movies\", []string{\"*.mp4\"}, false)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"/Movies/a.mp4\", \"/Movies/b.mp4\"}, matches)\n}\n\nfunc TestExpandRemotePatternsCanKeepRelativeMatches(t *testing.T) {\n\tprovider := fakeRemotePatternProvider{\n\t\tgetPathFolderID: func(dirPath string) (string, error) {\n\t\t\trequire.Equal(t, \"/Movies/Kids\", dirPath)\n\t\t\treturn \"kids-id\", nil\n\t\t},\n\t\tgetFolderFileStatList: func(parentId string) ([]FileStat, error) {\n\t\t\trequire.Equal(t, \"kids-id\", parentId)\n\t\t\treturn []FileStat{\n\t\t\t\t{Name: \"a.srt\"},\n\t\t\t\t{Name: \"b.srt\"},\n\t\t\t}, nil\n\t\t},\n\t}\n\n\tmatches, err := ExpandRemotePatterns(provider, \"/Movies\", []string{\"Kids/*.srt\"}, true)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"Kids/a.srt\", \"Kids/b.srt\"}, matches)\n}\n\nfunc TestExpandRemotePatternsReturnsNoMatchError(t *testing.T) {\n\tprovider := fakeRemotePatternProvider{\n\t\tgetPathFolderID: func(dirPath string) (string, error) {\n\t\t\treturn \"movies-id\", nil\n\t\t},\n\t\tgetFolderFileStatList: func(parentId string) ([]FileStat, error) {\n\t\t\treturn []FileStat{{Name: \"note.txt\"}}, nil\n\t\t},\n\t}\n\n\t_, err := ExpandRemotePatterns(provider, \"/Movies\", []string{\"*.mp4\"}, false)\n\trequire.EqualError(t, err, \"no matches found for *.mp4\")\n}\n\nfunc TestExpandRemotePatternsPropagatesLookupErrors(t *testing.T) {\n\tprovider := fakeRemotePatternProvider{\n\t\tgetPathFolderID: func(dirPath string) (string, error) {\n\t\t\treturn \"\", errors.New(\"lookup failed\")\n\t\t},\n\t\tgetFolderFileStatList: func(parentId string) ([]FileStat, error) {\n\t\t\treturn nil, errors.New(\"should not list\")\n\t\t},\n\t}\n\n\t_, err := ExpandRemotePatterns(provider, \"/Movies\", []string{\"Kids/*.mp4\"}, false)\n\trequire.EqualError(t, err, \"lookup failed\")\n}\n"
  },
  {
    "path": "internal/api/pikpak.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nconst userAgent = `ANDROID-com.pikcloud.pikpak/1.21.0`\nconst clientID = `YNxT9w7GMdWvEOKa`\nconst clientSecret = `dbw2OtmVEeuUvIptb1Coyg`\n\ntype PikPak struct {\n\tAccount       string `json:\"account\"`\n\tPassword      string `json:\"password\"`\n\tJwtToken      string `json:\"token\"`\n\trefreshToken  string\n\tCaptchaToken  string `json:\"captchaToken\"`\n\tSub           string `json:\"userId\"`\n\tDeviceId      string `json:\"deviceId\"`\n\tRefreshSecond int64  `json:\"refreshSecond\"`\n\tclient        *http.Client\n\tctx           context.Context\n}\n\nfunc NewPikPak(account, password string) PikPak {\n\treturn NewPikPakWithContext(context.Background(), account, password)\n}\n\nfunc NewPikPakWithContext(ctx context.Context, account, password string) PikPak {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tclient := &http.Client{\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t},\n\t}\n\tif conf.Config.UseProxy() {\n\t\tproxyUrl, err := url.Parse(conf.Config.Proxy)\n\t\tif err != nil {\n\t\t\tlogx.Warn(\"api\", \"url parse proxy error\", err)\n\t\t}\n\t\tp := http.ProxyURL(proxyUrl)\n\t\tclient.Transport = &http.Transport{\n\t\t\tProxy: p,\n\t\t}\n\t\thttp.DefaultClient.Transport = &http.Transport{\n\t\t\tProxy: p,\n\t\t}\n\t}\n\tn := md5.Sum([]byte(account))\n\treturn PikPak{\n\t\tAccount:  account,\n\t\tPassword: password,\n\t\tDeviceId: hex.EncodeToString(n[:]),\n\t\tclient:   client,\n\t\tctx:      ctx,\n\t}\n}\n\nfunc (p *PikPak) requestContext() context.Context {\n\tif p != nil && p.ctx != nil {\n\t\treturn p.ctx\n\t}\n\treturn context.Background()\n}\n\nfunc (p *PikPak) WithContext(ctx context.Context) *PikPak {\n\tif p == nil {\n\t\treturn nil\n\t}\n\tclone := *p\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tclone.ctx = ctx\n\treturn &clone\n}\n\nfunc (p *PikPak) newRequest(method, url string, body io.Reader) (*http.Request, error) {\n\treturn http.NewRequestWithContext(p.requestContext(), method, url, body)\n}\n\n// login performs the full credential-based login flow.\nfunc (p *PikPak) login() error {\n\tcaptchaToken, err := p.getCaptchaToken()\n\tif err != nil {\n\t\treturn err\n\t}\n\tm := make(map[string]string)\n\tm[\"client_id\"] = clientID\n\tm[\"client_secret\"] = clientSecret\n\tm[\"grant_type\"] = \"password\"\n\tm[\"username\"] = p.Account\n\tm[\"password\"] = p.Password\n\tm[\"captcha_token\"] = captchaToken\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq, err := p.newRequest(\"POST\", \"https://user.mypikpak.com/v1/auth/signin\", bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\n\tif error_code != 0 {\n\t\treturn fmt.Errorf(\"login error: %v\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\n\tp.JwtToken = jsoniter.Get(bs, \"access_token\").ToString()\n\tp.refreshToken = jsoniter.Get(bs, \"refresh_token\").ToString()\n\tp.Sub = jsoniter.Get(bs, \"sub\").ToString()\n\tp.RefreshSecond = jsoniter.Get(bs, \"expires_in\").ToInt64()\n\treturn nil\n}\n\nfunc (p *PikPak) getCaptchaToken() (string, error) {\n\tm := make(map[string]any)\n\tm[\"client_id\"] = clientID\n\tm[\"device_id\"] = p.DeviceId\n\tm[\"action\"] = \"POST:https://user.mypikpak.com/v1/auth/signin\"\n\tm[\"meta\"] = map[string]string{\n\t\t\"username\": p.Account,\n\t}\n\tbody, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq, err := p.newRequest(\"POST\", \"https://user.mypikpak.com/v1/shield/captcha/init\", bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Add(\"Content-Type\", \"application/json\")\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\tif error_code != 0 {\n\t\treturn \"\", fmt.Errorf(\"get captcha error: %v\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\treturn jsoniter.Get(bs, \"captcha_token\").ToString(), nil\n}\n\nfunc (p *PikPak) sendRequest(req *http.Request) ([]byte, error) {\n\tp.setHeader(req)\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbs, err := io.ReadAll(resp.Body)\n\tdefer resp.Body.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bs, nil\n}\n\nfunc (p *PikPak) setHeader(req *http.Request) {\n\tif p.JwtToken != \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+p.JwtToken)\n\t}\n\treq.Header.Set(\"User-Agent\", userAgent)\n\treq.Header.Set(\"X-Device-Id\", p.DeviceId)\n}\n\n// Login reuses a cached session first and falls back to full login when needed.\nfunc (p *PikPak) Login() error {\n\tif err := p.loadSession(); err == nil {\n\t\tif !p.isTokenExpired() {\n\t\t\tlogx.Debugln(\"session\", \"session valid, skip login\")\n\t\t\treturn nil\n\t\t}\n\t\tlogx.Debugln(\"session\", \"access_token expired, trying refresh_token\")\n\t\tif err = p.RefreshToken(); err == nil {\n\t\t\tp.saveSessionBestEffort()\n\t\t\treturn nil\n\t\t}\n\t\tlogx.Debugln(\"session\", \"refresh failed, fallback to full login:\", err)\n\t} else {\n\t\tlogx.Debugln(\"session\", \"load session failed, fallback to full login:\", err)\n\t}\n\tif err := p.login(); err != nil {\n\t\treturn err\n\t}\n\tp.saveSessionBestEffort()\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/quota.go",
    "content": "package api\n\nimport (\n\t\"strconv\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\ntype QuotaMessage struct {\n\tKind      string `json:\"kind\"`\n\tQuota     Quota  `json:\"quota\"`\n\tExpiresAt string `json:\"expires_at\"`\n\tQuotas    Quotas `json:\"quotas\"`\n}\ntype Quota struct {\n\tKind           string `json:\"kind\"`\n\tLimit          string `json:\"limit\"`\n\tUsage          string `json:\"usage\"`\n\tUsageInTrash   string `json:\"usage_in_trash\"`\n\tPlayTimesLimit string `json:\"play_times_limit\"`\n\tPlayTimesUsage string `json:\"play_times_usage\"`\n}\n\n// Remaining returns the unused quota amount.\nfunc (q Quota) Remaining() (int64, error) {\n\tlimit, err := strconv.ParseInt(q.Limit, 10, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tusage, err := strconv.ParseInt(q.Usage, 10, 64)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn limit - usage, nil\n}\n\ntype Quotas struct {\n\tCloudDownload Quota `json:\"cloud_download\"`\n}\n\ntype TransferMessage struct {\n\tTransfer TransferQuotaCollection `json:\"transfer\"`\n\tBase     TransferQuotaBase       `json:\"base\"`\n}\n\ntype TransferQuotaCollection struct {\n\tOffline  TransferQuota `json:\"offline\"`\n\tDownload TransferQuota `json:\"download\"`\n\tUpload   TransferQuota `json:\"upload\"`\n}\n\ntype TransferQuotaBase struct {\n\tOffline  TransferQuota `json:\"offline\"`\n\tDownload TransferQuota `json:\"download\"`\n\tUpload   TransferQuota `json:\"upload\"`\n}\n\ntype TransferQuota struct {\n\tInfo        string `json:\"info\"`\n\tTotalAssets int64  `json:\"total_assets\"`\n\tAssets      int64  `json:\"assets\"`\n\tSize        int64  `json:\"size\"`\n}\n\nfunc (q TransferQuota) Remaining() int64 {\n\treturn q.TotalAssets - q.Assets\n}\n\n// GetQuota get cloud quota\nfunc (p *PikPak) GetQuota() (QuotaMessage, error) {\n\treq, err := p.newRequest(\"GET\", \"https://api-drive.mypikpak.com/drive/v1/about\", nil)\n\tif err != nil {\n\t\treturn QuotaMessage{}, err\n\t}\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn QuotaMessage{}, err\n\t}\n\tvar quotaMessage QuotaMessage\n\terr = jsoniter.Unmarshal(bs, &quotaMessage)\n\tif err != nil {\n\t\treturn QuotaMessage{}, err\n\t}\n\treturn quotaMessage, nil\n}\n\n// GetTransferQuota gets monthly transfer quota.\nfunc (p *PikPak) GetTransferQuota() (TransferMessage, error) {\n\treq, err := p.newRequest(\"GET\", \"https://api-drive.mypikpak.com/vip/v1/quantity/list?type=transfer&limit=200\", nil)\n\tif err != nil {\n\t\treturn TransferMessage{}, err\n\t}\n\tbs, err := p.sendRequest(req)\n\tif err != nil {\n\t\treturn TransferMessage{}, err\n\t}\n\tvar transferMessage TransferMessage\n\terr = jsoniter.Unmarshal(bs, &transferMessage)\n\tif err != nil {\n\t\treturn TransferMessage{}, err\n\t}\n\treturn transferMessage, nil\n}\n"
  },
  {
    "path": "internal/api/quota_test.go",
    "content": "package api\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestQuotaRemaining(t *testing.T) {\n\tremaining, err := (Quota{Limit: \"10\", Usage: \"3\"}).Remaining()\n\trequire.NoError(t, err)\n\tassert.Equal(t, int64(7), remaining)\n}\n\nfunc TestQuotaRemainingInvalid(t *testing.T) {\n\t_, err := (Quota{Limit: \"bad\", Usage: \"3\"}).Remaining()\n\trequire.Error(t, err)\n}\n\nfunc TestTransferQuotaRemaining(t *testing.T) {\n\tremaining := (TransferQuota{TotalAssets: 10, Assets: 3}).Remaining()\n\tassert.Equal(t, int64(7), remaining)\n}\n"
  },
  {
    "path": "internal/api/refresh_token.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator/go\"\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc (p *PikPak) RefreshToken() error {\n\turl := \"https://user.mypikpak.com/v1/auth/token\"\n\tm := map[string]string{\n\t\t\"client_id\":     clientID,\n\t\t\"client_secret\": clientSecret,\n\t\t\"grant_type\":    \"refresh_token\",\n\t\t\"refresh_token\": p.refreshToken,\n\t}\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := p.newRequest(\"POST\", url, bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := gjson.GetBytes(bs, \"error_code\").Int()\n\tif error_code != 0 {\n\t\t// refresh token failed\n\t\tif error_code == 4126 {\n\t\t\t// Retry with the full login flow when the refresh token is no longer valid.\n\t\t\treturn p.login()\n\t\t}\n\t\treturn fmt.Errorf(\"refresh token error message: %d\", gjson.GetBytes(bs, \"error\").Int())\n\t}\n\t// logrus.Debug(\"refresh: \", string(bs))\n\tp.JwtToken = gjson.GetBytes(bs, \"access_token\").String()\n\tp.refreshToken = gjson.GetBytes(bs, \"refresh_token\").String()\n\tp.RefreshSecond = gjson.GetBytes(bs, \"expires_in\").Int()\n\tlogx.Debugln(\"session\", \"refresh token succeeded\")\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/session.go",
    "content": "package api\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n)\n\nconst sessionExpirySkew = 5 * 60\n\n// sessionData is the on-disk representation of cached auth tokens.\ntype sessionData struct {\n\tJwtToken     string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tSub          string `json:\"sub\"`\n\t// ExpiresAt stores the access token expiration time as a Unix timestamp in seconds.\n\tExpiresAt int64 `json:\"expires_at\"`\n}\n\n// saveSession persists the current token state to the local session file.\nfunc (p *PikPak) saveSession() error {\n\tpath, err := sessionFile(p.Account)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := utils.CreateDirIfNotExist(filepath.Dir(path)); err != nil {\n\t\treturn fmt.Errorf(\"create session dir error: %w\", err)\n\t}\n\tdata := sessionData{\n\t\tJwtToken:     p.JwtToken,\n\t\tRefreshToken: p.refreshToken,\n\t\tSub:          p.Sub,\n\t\t// Treat the token as expired slightly early to avoid using a near-expiry session.\n\t\tExpiresAt: time.Now().Unix() + p.RefreshSecond - sessionExpirySkew,\n\t}\n\n\tbs, err := json.Marshal(data)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshal session error: %w\", err)\n\t}\n\tif err = os.WriteFile(path, bs, 0600); err != nil {\n\t\treturn fmt.Errorf(\"write session file error: %w\", err)\n\t}\n\tlogx.Debugln(\"session\", \"session saved to\", path)\n\treturn nil\n}\n\n// loadSession restores cached tokens from disk into the current client.\nfunc (p *PikPak) loadSession() error {\n\tpath, err := sessionFile(p.Account)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbs, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read session file error: %w\", err)\n\t}\n\tvar data sessionData\n\tif err = json.Unmarshal(bs, &data); err != nil {\n\t\treturn fmt.Errorf(\"unmarshal session error: %w\", err)\n\t}\n\n\tp.JwtToken = data.JwtToken\n\tp.refreshToken = data.RefreshToken\n\tp.Sub = data.Sub\n\tp.RefreshSecond = data.ExpiresAt - time.Now().Unix()\n\tlogx.Debugln(\"session\", \"session loaded from\", path)\n\treturn nil\n}\n\n// isTokenExpired reports whether the cached access token should be treated as expired.\nfunc (p *PikPak) isTokenExpired() bool {\n\treturn p.RefreshSecond <= 0\n}\n\nfunc (p *PikPak) saveSessionBestEffort() {\n\tif err := p.saveSession(); err != nil {\n\t\tlogx.Warn(\"session\", \"save session failed:\", err)\n\t}\n}\n\nfunc sessionFile(account string) (string, error) {\n\tconfigDir, err := os.UserConfigDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get config dir error: %w\", err)\n\t}\n\thash := md5.Sum([]byte(account))\n\tfilename := fmt.Sprintf(\"session_%s.json\", hex.EncodeToString(hash[:]))\n\treturn filepath.Join(configDir, \"pikpakcli\", filename), nil\n}\n"
  },
  {
    "path": "internal/api/sha.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nfunc (p *PikPak) CreateShaFile(parentId, fileName, size, sha string) error {\n\tm := map[string]interface{}{\n\t\t\"body\": map[string]string{\n\t\t\t\"duration\": \"\",\n\t\t\t\"width\":    \"\",\n\t\t\t\"height\":   \"\",\n\t\t},\n\t\t\"kind\":        FileKindFile,\n\t\t\"name\":        fileName,\n\t\t\"size\":        size,\n\t\t\"hash\":        sha,\n\t\t\"upload_type\": \"UPLOAD_TYPE_RESUMABLE\",\n\t\t\"objProvider\": map[string]string{\n\t\t\t\"provider\": \"UPLOAD_TYPE_UNKNOWN\",\n\t\t},\n\t}\n\tif parentId != \"\" {\n\t\tm[\"parent_id\"] = parentId\n\t}\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn err\n\t}\nSTART:\n\treq, err := p.newRequest(\"POST\", \"https://api-drive.mypikpak.com/drive/v1/files\", bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\treq.Header.Set(\"Product_flavor_name\", \"cha\")\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Client-Version-Code\", \"10083\")\n\treq.Header.Set(\"X-Peer-Id\", p.DeviceId)\n\treq.Header.Set(\"X-User-Region\", \"1\")\n\treq.Header.Set(\"X-Alt-Capability\", \"3\")\n\treq.Header.Set(\"Country\", \"CN\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr := p.AuthCaptchaToken(\"POST:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn fmt.Errorf(\"upload file error: %s\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\tfile := jsoniter.Get(bs, \"file\")\n\tphase := file.Get(\"phase\").ToString()\n\tif phase == \"PHASE_TYPE_COMPLETE\" {\n\t\treturn nil\n\t} else {\n\t\treturn fmt.Errorf(\"create file error: %s\", phase)\n\t}\n}\n"
  },
  {
    "path": "internal/api/upload.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/xml\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakhash\"\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\ntype OssArgs struct {\n\tBucket          string `json:\"bucket\"`\n\tAccessKeyId     string `json:\"access_key_id\"`\n\tAccessKeySecret string `json:\"access_key_secret\"`\n\tEndPoint        string `json:\"endpoint\"`\n\tKey             string `json:\"key\"`\n\tSecurityToken   string `json:\"security_token\"`\n}\n\n// 256k\nvar defaultChunkSize int64 = 1 << 18\nvar Concurrent int64 = 1 << 4\n\nvar ErrNotFoundFolder = errors.New(\"not found pikpak folder\")\n\nfunc (p *PikPak) UploadFile(parentId, path string) error {\n\tfileName := filepath.Base(path)\n\tfileState, err := os.Stat(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfileSize := fileState.Size()\n\tph := pikpakhash.Default()\n\thash, err := ph.HashFromPath(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tm := map[string]interface{}{\n\t\t\"body\": map[string]string{\n\t\t\t\"duration\": \"\",\n\t\t\t\"width\":    \"\",\n\t\t\t\"height\":   \"\",\n\t\t},\n\t\t\"kind\":        FileKindFile,\n\t\t\"name\":        fileName,\n\t\t\"size\":        fmt.Sprintf(\"%d\", fileSize),\n\t\t\"hash\":        hash,\n\t\t\"upload_type\": \"UPLOAD_TYPE_RESUMABLE\",\n\t\t\"objProvider\": map[string]string{\n\t\t\t\"provider\": \"UPLOAD_TYPE_UNKNOWN\",\n\t\t},\n\t}\n\tif parentId != \"\" {\n\t\tm[\"parent_id\"] = parentId\n\t}\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn err\n\t}\nSTART:\n\treq, err := p.newRequest(\"POST\", \"https://api-drive.mypikpak.com/drive/v1/files\", bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\treq.Header.Set(\"Product_flavor_name\", \"cha\")\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Client-Version-Code\", \"10083\")\n\treq.Header.Set(\"X-Peer-Id\", p.DeviceId)\n\treq.Header.Set(\"X-User-Region\", \"1\")\n\treq.Header.Set(\"X-Alt-Capability\", \"3\")\n\treq.Header.Set(\"Country\", \"CN\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr = p.AuthCaptchaToken(\"POST:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn fmt.Errorf(\"upload file error: %s\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\t// logrus.Debug(string(bs))\n\tfile := jsoniter.Get(bs, \"file\")\n\tphase := file.Get(\"phase\").ToString()\n\tlogx.Debug(\"upload\", \"path: \", path, \" phase: \", phase)\n\n\tswitch phase {\n\tcase \"PHASE_TYPE_COMPLETE\":\n\t\tlogx.Debug(\"upload\", path, \" upload file complete\")\n\t\treturn nil\n\tcase \"PHASE_TYPE_PENDING\":\n\t\t// break switch\n\t\tbreak\n\t}\n\tparams := jsoniter.Get(bs, \"resumable\").Get(\"params\")\n\n\taccessKeyId := params.Get(\"access_key_id\").ToString()\n\taccessKeySecret := params.Get(\"access_key_secret\").ToString()\n\tbucket := params.Get(\"bucket\").ToString()\n\tendpoint := params.Get(\"endpoint\").ToString()\n\tkey := params.Get(\"key\").ToString()\n\tsecurityToken := params.Get(\"security_token\").ToString()\n\tossArgs := OssArgs{\n\t\tBucket:          bucket,\n\t\tAccessKeyId:     accessKeyId,\n\t\tAccessKeySecret: accessKeySecret,\n\t\tEndPoint:        endpoint,\n\t\tKey:             key,\n\t\tSecurityToken:   securityToken,\n\t}\n\n\tuploadId := p.beforeUpload(ossArgs)\n\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\twait := new(sync.WaitGroup)\n\tin_wait := new(sync.WaitGroup)\n\tch := make(chan Part, Concurrent)\n\n\tvar chunkSize = int64(math.Ceil(float64(fileSize) / 10000))\n\tif chunkSize < defaultChunkSize {\n\t\tchunkSize = defaultChunkSize\n\t}\n\n\tfor i := int64(0); i < Concurrent; i++ {\n\t\twait.Add(1)\n\t\tgo uploadChunk(p.requestContext(), wait, ch, f, chunkSize, fileSize, i, ossArgs, uploadId)\n\t}\n\tdonePartSlice := make([]Part, 0)\n\tin_wait.Add(1)\n\tgo func() {\n\t\tdefer in_wait.Done()\n\t\tfor p := range ch {\n\t\t\tdonePartSlice = append(donePartSlice, p)\n\t\t}\n\t}()\n\twait.Wait()\n\tclose(ch)\n\tin_wait.Wait()\n\tsort.Slice(donePartSlice, func(i, j int) bool {\n\t\tiNum, _ := strconv.Atoi(donePartSlice[i].PartNumber)\n\t\tjNum, _ := strconv.Atoi(donePartSlice[j].PartNumber)\n\t\treturn iNum < jNum\n\t})\n\targs := CompleteMultipartUpload{\n\t\tPart: donePartSlice,\n\t}\n\terr = p.afterUpload(&args, ossArgs, uploadId)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc uploadChunk(ctx context.Context, wait *sync.WaitGroup, ch chan Part, f *os.File, ChunkSize, fileSize int64, part int64, ossArgs OssArgs, uploadId string) {\n\tdefer wait.Done()\n\tif part*ChunkSize >= fileSize {\n\t\treturn\n\t}\n\tbuf := make([]byte, ChunkSize)\n\tvar offset = part * ChunkSize\n\tfor offset < fileSize {\n\t\tn, _ := f.ReadAt(buf, offset)\n\t\t// if err != nil {\n\t\t// \t// logrus.Error(err)\n\t\t// }\n\t\tif n > 0 {\n\t\t\tvalue := url.Values{}\n\t\t\tvalue.Add(\"uploadId\", uploadId)\n\t\t\tvalue.Add(\"partNumber\", fmt.Sprintf(\"%d\", part+1))\n\t\t\treq, err := http.NewRequestWithContext(ctx, \"PUT\", fmt.Sprintf(\"https://%s/%s?%s\",\n\t\t\t\tossArgs.EndPoint,\n\t\t\t\tossArgs.Key,\n\t\t\t\tvalue.Encode()), bytes.NewBuffer(buf[:n]))\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tnow := time.Now().UTC()\n\t\t\treq.Header.Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\treq.Header.Set(\"X-OSS-Security-Token\", ossArgs.SecurityToken)\n\t\t\treq.Header.Set(\"Date\", now.Format(http.TimeFormat))\n\t\t\treq.Header.Set(\"Authorization\", \"OSS \"+ossArgs.AccessKeyId+\":\"+hmacAuthorization(req, nil, now, ossArgs))\n\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// bs, _ := io.ReadAll(resp.Body)\n\t\t\teTag := strings.Trim(resp.Header.Get(\"ETag\"), \"\\\"\")\n\t\t\tp := Part{\n\t\t\t\tPartNumber: fmt.Sprintf(\"%d\", part+1),\n\t\t\t\tETag:       eTag,\n\t\t\t}\n\t\t\tch <- p\n\t\t\tresp.Body.Close()\n\t\t}\n\t\tpart = part + Concurrent\n\t\toffset = part * ChunkSize\n\t}\n}\n\ntype header struct {\n\tkey string\n\tval string\n}\n\ntype CompleteMultipartUpload struct {\n\tPart []Part `xml:\"Part\"`\n}\ntype Part struct {\n\tPartNumber string `xml:\"PartNumber\"`\n\tETag       string `xml:\"ETag\"`\n}\n\nfunc hmacAuthorization(req *http.Request, body []byte, time time.Time, ossArgs OssArgs) string {\n\tdate := time.UTC().Format(http.TimeFormat)\n\tstringBuilder := new(strings.Builder)\n\tstringBuilder.WriteString(req.Method + \"\\n\")\n\tif body == nil {\n\t\tstringBuilder.WriteString(\"\\n\")\n\t} else {\n\t\t// digest := md5.New()\n\t\t// digest.Write(body)\n\t\t// sign := base64.StdEncoding.EncodeToString(digest.Sum(nil))\n\t\t// stringBuilder.WriteString(sign + \"\\n\")\n\t\tstringBuilder.WriteString(\"\\n\")\n\t}\n\tstringBuilder.WriteString(req.Header.Get(\"Content-Type\") + \"\\n\")\n\tstringBuilder.WriteString(date + \"\\n\")\n\n\theaderSlice := make([]header, 0)\n\tfor k, v := range req.Header {\n\t\theaderK := strings.ToLower(k)\n\t\tif strings.Contains(headerK, \"x-oss-\") && len(v) > 0 {\n\t\t\theaderSlice = append(headerSlice, header{headerK, v[0]})\n\t\t}\n\t}\n\n\t// 从小到大排序\n\tsort.Slice(headerSlice, func(i, j int) bool {\n\t\treturn headerSlice[i].key < headerSlice[j].key\n\t})\n\tfor _, hd := range headerSlice {\n\t\tstringBuilder.WriteString(hd.key + \":\" + hd.val + \"\\n\")\n\t}\n\n\tstringBuilder.WriteString(\"/\" + ossArgs.Bucket + req.URL.Path + \"?\" + req.URL.RawQuery)\n\n\th := hmac.New(sha1.New, []byte(ossArgs.AccessKeySecret))\n\th.Write([]byte(stringBuilder.String()))\n\treturn base64.StdEncoding.EncodeToString(h.Sum(nil))\n}\n\nfunc (p *PikPak) beforeUpload(ossArgs OssArgs) string {\n\treq, err := p.newRequest(\"POST\", \"https://\"+ossArgs.EndPoint+\"/\"+ossArgs.Key+\"?uploads\", nil)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\ttime := time.Now().UTC()\n\treq.Header.Set(\"Date\", time.Format(http.TimeFormat))\n\treq.Header.Set(\"Content-Type\", \"application/octet-stream\")\n\treq.Header.Set(\"User-Agent\", \"aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)\")\n\treq.Header.Set(\"X-Oss-Security-Token\", ossArgs.SecurityToken)\n\treq.Header.Set(\"Authorization\",\n\t\tfmt.Sprintf(\"%s %s:%s\",\n\t\t\t\"OSS\",\n\t\t\tossArgs.AccessKeyId,\n\t\t\thmacAuthorization(req, nil, time, ossArgs),\n\t\t))\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tdefer resp.Body.Close()\n\tbs, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\ttype InitiateMultipartUploadResult struct {\n\t\tBucket   string `xml:\"Bucket\"`\n\t\tKey      string `xml:\"Key\"`\n\t\tUploadId string `xml:\"UploadId\"`\n\t}\n\tres := new(InitiateMultipartUploadResult)\n\n\terr = xml.Unmarshal(bs, res)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn res.UploadId\n}\n\nfunc (p *PikPak) afterUpload(args *CompleteMultipartUpload, ossArgs OssArgs, uploadId string) error {\n\tbs, err := xml.Marshal(args)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := p.newRequest(\"POST\", \"https://\"+ossArgs.EndPoint+\"/\"+ossArgs.Key+\"?uploadId=\"+uploadId, bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\ttime := time.Now().UTC()\n\treq.Header.Set(\"Date\", time.Format(http.TimeFormat))\n\treq.Header.Set(\"Content-Type\", \"application/octet-stream\")\n\treq.Header.Set(\"User-Agent\", \"aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)\")\n\treq.Header.Set(\"X-Oss-Security-Token\", ossArgs.SecurityToken)\n\treq.Header.Set(\"Authorization\",\n\t\tfmt.Sprintf(\"%s %s:%s\",\n\t\t\t\"OSS\",\n\t\t\tossArgs.AccessKeyId,\n\t\t\thmacAuthorization(req, nil, time, ossArgs),\n\t\t))\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\t_, err = io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/api/url.go",
    "content": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nfunc (p *PikPak) CreateUrlFile(parentId, url string) error {\n\tm := map[string]interface{}{\n\t\t\"kind\":        FileKindFile,\n\t\t\"upload_type\": \"UPLOAD_TYPE_URL\",\n\t\t\"url\": map[string]string{\n\t\t\t\"url\": url,\n\t\t},\n\t}\n\tif parentId != \"\" {\n\t\tm[\"parent_id\"] = parentId\n\t}\n\tbs, err := jsoniter.Marshal(&m)\n\tif err != nil {\n\t\treturn err\n\t}\nSTART:\n\treq, err := p.newRequest(\"POST\", \"https://api-drive.mypikpak.com/drive/v1/files\", bytes.NewBuffer(bs))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\treq.Header.Set(\"Product_flavor_name\", \"cha\")\n\treq.Header.Set(\"X-Captcha-Token\", p.CaptchaToken)\n\treq.Header.Set(\"X-Client-Version-Code\", \"10083\")\n\treq.Header.Set(\"X-Peer-Id\", p.DeviceId)\n\treq.Header.Set(\"X-User-Region\", \"1\")\n\treq.Header.Set(\"X-Alt-Capability\", \"3\")\n\treq.Header.Set(\"Country\", \"CN\")\n\tbs, err = p.sendRequest(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\terror_code := jsoniter.Get(bs, \"error_code\").ToInt()\n\tif error_code != 0 {\n\t\tif error_code == 9 {\n\t\t\terr := p.AuthCaptchaToken(\"POST:/drive/v1/files\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tgoto START\n\t\t}\n\t\treturn fmt.Errorf(\"upload file error: %s\", jsoniter.Get(bs, \"error\").ToString())\n\t}\n\n\ttask := jsoniter.Get(bs, \"task\")\n\tlogx.Debug(\"api\", task.ToString())\n\t// phase := task.Get(\"phase\").ToString()\n\t// if phase == \"PHASE_TYPE_COMPLETE\" {\n\t// \treturn nil\n\t// } else {\n\t// \treturn fmt.Errorf(\"create file error: %s\", phase)\n\t// }\n\treturn nil\n}\n"
  },
  {
    "path": "internal/logx/logx.go",
    "content": "package logx\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"strings\"\n)\n\nvar enabledTopics = map[string]struct{}{}\nvar debugEnabled bool\nvar logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{\n\tLevel: slog.LevelError,\n}))\n\nfunc Init(debug bool, topics []string) {\n\tenabledTopics = parseTopics(topics)\n\tdebugEnabled = debug || envEnabled(\"PIKPAKCLI_DEBUG\") || len(enabledTopics) > 0\n\n\tlevel := slog.LevelError\n\tif debugEnabled {\n\t\tlevel = slog.LevelDebug\n\t}\n\tlogger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{\n\t\tLevel: level,\n\t}))\n\tslog.SetDefault(logger)\n}\n\nfunc Enabled(topic string) bool {\n\tif !debugEnabled {\n\t\treturn false\n\t}\n\tif topic == \"\" {\n\t\treturn true\n\t}\n\tif _, ok := enabledTopics[\"all\"]; ok {\n\t\treturn true\n\t}\n\t_, ok := enabledTopics[topic]\n\treturn ok\n}\n\nfunc Debug(topic string, args ...any) {\n\tif Enabled(topic) {\n\t\tlogger.Debug(fmt.Sprint(args...))\n\t}\n}\n\nfunc Debugln(topic string, args ...any) {\n\tif Enabled(topic) {\n\t\tlogger.Debug(fmt.Sprintln(args...))\n\t}\n}\n\nfunc Warn(topic string, args ...any) {\n\tif Enabled(topic) {\n\t\tlogger.Warn(fmt.Sprint(args...))\n\t}\n}\n\nfunc Warnf(topic, format string, args ...any) {\n\tif Enabled(topic) {\n\t\tlogger.Warn(fmt.Sprintf(format, args...))\n\t}\n}\n\nfunc Error(args ...any) {\n\tlogger.Error(fmt.Sprint(args...))\n}\n\nfunc Errorf(format string, args ...any) {\n\tlogger.Error(fmt.Sprintf(format, args...))\n}\n\nfunc parseTopics(topics []string) map[string]struct{} {\n\tres := map[string]struct{}{}\n\tfor _, topic := range topics {\n\t\tfor _, item := range strings.Split(topic, \",\") {\n\t\t\titem = strings.TrimSpace(strings.ToLower(item))\n\t\t\tif item == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tres[item] = struct{}{}\n\t\t}\n\t}\n\tenvTopics := strings.Split(os.Getenv(\"PIKPAKCLI_DEBUG_TOPICS\"), \",\")\n\tfor _, item := range envTopics {\n\t\titem = strings.TrimSpace(strings.ToLower(item))\n\t\tif item == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tres[item] = struct{}{}\n\t}\n\tif envEnabled(\"PIKPAKCLI_DEBUG\") {\n\t\tres[\"all\"] = struct{}{}\n\t}\n\treturn res\n}\n\nfunc envEnabled(key string) bool {\n\tvalue := strings.TrimSpace(strings.ToLower(os.Getenv(key)))\n\tswitch value {\n\tcase \"1\", \"true\", \"yes\", \"on\", \"debug\", \"all\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "internal/shell/open.go",
    "content": "package shell\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n)\n\nconst (\n\topenCategoryDefault = \"default\"\n\topenCategoryText    = \"text\"\n\topenCategoryImage   = \"image\"\n\topenCategoryVideo   = \"video\"\n\topenCategoryAudio   = \"audio\"\n\topenCategoryPDF     = \"pdf\"\n)\n\ntype openFileService interface {\n\tGetFileByPath(path string) (api.FileStat, error)\n\tGetFile(fileID string) (api.File, error)\n}\n\nfunc handleOpenCommand(p openFileService, currentPath string, args []string) error {\n\tif len(args) == 0 {\n\t\treturn errors.New(\"usage: open <remote-file> [remote-file...]\")\n\t}\n\n\tfor _, arg := range args {\n\t\ttargetPath := resolveShellPath(currentPath, arg)\n\t\tstat, err := p.GetFileByPath(targetPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"open: %s: %w\", targetPath, err)\n\t\t}\n\t\tif stat.Kind == api.FileKindFolder {\n\t\t\treturn fmt.Errorf(\"open: %s: folders are not supported\", targetPath)\n\t\t}\n\n\t\tfile, err := p.GetFile(stat.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"open: %s: get file failed: %w\", targetPath, err)\n\t\t}\n\n\t\topenTarget, err := resolveOpenTarget(&file)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"open: %s: resolve open target failed: %w\", targetPath, err)\n\t\t}\n\n\t\tif err := openWithLocalApp(openTarget, classifyOpenCategory(file.Name)); err != nil {\n\t\t\treturn fmt.Errorf(\"open: %s: launch local app failed: %w\", targetPath, err)\n\t\t}\n\n\t\tfmt.Printf(\"Opened %s -> %s\\n\", targetPath, openTarget)\n\t}\n\n\treturn nil\n}\n\nfunc resolveOpenTarget(file *api.File) (string, error) {\n\tif classifyOpenCategory(file.Name) == openCategoryVideo {\n\t\tif url := remoteVideoOpenURL(file); url != \"\" {\n\t\t\treturn url, nil\n\t\t}\n\t}\n\n\treturn cacheOpenFile(file)\n}\n\nfunc cacheOpenFile(file *api.File) (string, error) {\n\tcacheRoot, err := openCacheRoot()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcacheDir := filepath.Join(cacheRoot, file.ID)\n\tif err := utils.CreateDirIfNotExist(cacheDir); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tlocalPath := filepath.Join(cacheDir, file.Name)\n\tmatched, err := localFileMatchesRemoteSize(localPath, file.Size)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif matched {\n\t\treturn localPath, nil\n\t}\n\n\tif err := file.Download(localPath, nil); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn localPath, nil\n}\n\nfunc openCacheRoot() (string, error) {\n\tif strings.TrimSpace(conf.Config.Open.DownloadDir) != \"\" {\n\t\troot := utils.ExpandLocalPath(conf.Config.Open.DownloadDir)\n\t\tif err := utils.CreateDirIfNotExist(root); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn root, nil\n\t}\n\n\tcacheDir, err := os.UserCacheDir()\n\tif err != nil {\n\t\tcacheDir = filepath.Join(os.TempDir(), \"pikpakcli\")\n\t}\n\n\troot := filepath.Join(cacheDir, \"pikpakcli\", \"open\")\n\tif err := utils.CreateDirIfNotExist(root); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn root, nil\n}\n\nfunc localFileMatchesRemoteSize(path string, remoteSize string) (bool, error) {\n\texpectedSize, err := strconv.ParseInt(remoteSize, 10, 64)\n\tif err != nil || expectedSize < 0 {\n\t\treturn false, nil\n\t}\n\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\n\treturn info.Size() == expectedSize, nil\n}\n\nfunc classifyOpenCategory(name string) string {\n\tswitch strings.ToLower(filepath.Ext(name)) {\n\tcase \".txt\", \".md\", \".markdown\", \".log\", \".json\", \".yaml\", \".yml\", \".toml\", \".ini\", \".cfg\", \".conf\", \".csv\",\n\t\t\".go\", \".rs\", \".py\", \".js\", \".ts\", \".tsx\", \".jsx\", \".java\", \".c\", \".cc\", \".cpp\", \".h\", \".hpp\", \".sh\", \".zsh\":\n\t\treturn openCategoryText\n\tcase \".jpg\", \".jpeg\", \".png\", \".gif\", \".bmp\", \".webp\", \".svg\", \".heic\", \".tiff\":\n\t\treturn openCategoryImage\n\tcase \".mp4\", \".mkv\", \".mov\", \".avi\", \".wmv\", \".flv\", \".webm\", \".m4v\":\n\t\treturn openCategoryVideo\n\tcase \".mp3\", \".flac\", \".wav\", \".aac\", \".m4a\", \".ogg\", \".opus\":\n\t\treturn openCategoryAudio\n\tcase \".pdf\":\n\t\treturn openCategoryPDF\n\tdefault:\n\t\treturn openCategoryDefault\n\t}\n}\n\nfunc remoteVideoOpenURL(file *api.File) string {\n\tfor _, media := range file.Medias {\n\t\tif media.IsDefault && media.IsVisible && strings.TrimSpace(media.Link.URL) != \"\" {\n\t\t\treturn media.Link.URL\n\t\t}\n\t}\n\tfor _, media := range file.Medias {\n\t\tif media.IsVisible && strings.TrimSpace(media.Link.URL) != \"\" {\n\t\t\treturn media.Link.URL\n\t\t}\n\t}\n\tfor _, media := range file.Medias {\n\t\tif strings.TrimSpace(media.Link.URL) != \"\" {\n\t\t\treturn media.Link.URL\n\t\t}\n\t}\n\tif strings.TrimSpace(file.Links.ApplicationOctetStream.URL) != \"\" {\n\t\treturn file.Links.ApplicationOctetStream.URL\n\t}\n\tif strings.TrimSpace(file.WebContentLink) != \"\" {\n\t\treturn file.WebContentLink\n\t}\n\treturn \"\"\n}\n\nfunc openWithLocalApp(target string, category string) error {\n\tname, args, err := buildOpenCommand(runtime.GOOS, conf.Config.Open, target, category)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcmd := exec.Command(name, args...)\n\treturn cmd.Start()\n}\n\nfunc buildOpenCommand(goos string, cfg conf.OpenConfig, path string, category string) (string, []string, error) {\n\tcommand := commandForCategory(cfg, category)\n\tif len(command) == 0 {\n\t\tcommand = defaultOpenCommand(goos, category)\n\t}\n\tif len(command) == 0 {\n\t\treturn \"\", nil, fmt.Errorf(\"unsupported platform: %s\", goos)\n\t}\n\n\tresolved := make([]string, 0, len(command)+1)\n\thasPlaceholder := false\n\tfor _, item := range command {\n\t\tif item == \"{path}\" {\n\t\t\tresolved = append(resolved, path)\n\t\t\thasPlaceholder = true\n\t\t\tcontinue\n\t\t}\n\t\tresolved = append(resolved, item)\n\t}\n\tif !hasPlaceholder {\n\t\tresolved = append(resolved, path)\n\t}\n\n\treturn resolved[0], resolved[1:], nil\n}\n\nfunc commandForCategory(cfg conf.OpenConfig, category string) []string {\n\tswitch category {\n\tcase openCategoryText:\n\t\tif len(cfg.Text) > 0 {\n\t\t\treturn append([]string{}, cfg.Text...)\n\t\t}\n\tcase openCategoryImage:\n\t\tif len(cfg.Image) > 0 {\n\t\t\treturn append([]string{}, cfg.Image...)\n\t\t}\n\tcase openCategoryVideo:\n\t\tif len(cfg.Video) > 0 {\n\t\t\treturn append([]string{}, cfg.Video...)\n\t\t}\n\tcase openCategoryAudio:\n\t\tif len(cfg.Audio) > 0 {\n\t\t\treturn append([]string{}, cfg.Audio...)\n\t\t}\n\tcase openCategoryPDF:\n\t\tif len(cfg.PDF) > 0 {\n\t\t\treturn append([]string{}, cfg.PDF...)\n\t\t}\n\t}\n\n\tif len(cfg.Default) > 0 {\n\t\treturn append([]string{}, cfg.Default...)\n\t}\n\treturn nil\n}\n\nfunc defaultOpenCommand(goos string, category string) []string {\n\tswitch goos {\n\tcase \"darwin\":\n\t\tswitch category {\n\t\tcase openCategoryText:\n\t\t\treturn []string{\"open\", \"-a\", \"TextEdit\"}\n\t\tcase openCategoryImage, openCategoryPDF:\n\t\t\treturn []string{\"open\", \"-a\", \"Preview\"}\n\t\tcase openCategoryVideo, openCategoryAudio:\n\t\t\treturn []string{\"open\", \"-a\", \"IINA\"}\n\t\tdefault:\n\t\t\treturn []string{\"open\"}\n\t\t}\n\tcase \"linux\":\n\t\treturn []string{\"xdg-open\"}\n\tcase \"windows\":\n\t\treturn []string{\"cmd\", \"/c\", \"start\", \"\"}\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "internal/shell/shell.go",
    "content": "package shell\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pikpakcli/internal/utils\"\n\t\"github.com/chzyer/readline\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\nvar builtInCommands = []string{\"cd\", \"clear\", \"exit\", \"help\", \"open\", \"quit\"}\n\nconst clearScreenSequence = \"\\033[H\\033[2J\"\n\ntype fileStatProvider interface {\n\tGetPathFolderId(dirPath string) (string, error)\n\tGetFolderFileStatList(parentId string) ([]api.FileStat, error)\n}\n\ntype shellAutoCompleter struct {\n\trootCmd        *cobra.Command\n\tfileStatSource fileStatProvider\n\tcurrentPath    func() string\n}\n\n// Start starts the interactive shell\nfunc Start(rootCmd *cobra.Command) {\n\tfmt.Println(\"PikPak CLI Interactive Shell\")\n\tfmt.Println(\"Type 'help' for available commands, 'exit' or Ctrl-D to quit\")\n\tfmt.Println()\n\n\tcurrentPath := \"/\"\n\n\tp := api.NewPikPak(conf.Config.Username, conf.Config.Password)\n\tif err := p.Login(); err != nil {\n\t\tfmt.Println(\"Login failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\n\tl, err := readline.NewEx(&readline.Config{\n\t\tPrompt: promptForPath(currentPath),\n\t\tAutoComplete: &shellAutoCompleter{\n\t\t\trootCmd:        rootCmd,\n\t\t\tfileStatSource: &p,\n\t\t\tcurrentPath: func() string {\n\t\t\t\treturn currentPath\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil {\n\t\tfmt.Println(\"Initialize readline failed\")\n\t\tlogx.Error(err)\n\t\treturn\n\t}\n\tdefer l.Close()\n\n\tfor {\n\t\tinput, err := l.Readline()\n\n\t\tif isReadlineInterrupt(err) {\n\t\t\tfmt.Println()\n\t\t\tl.SetPrompt(promptForPath(currentPath))\n\t\t\tcontinue\n\t\t}\n\n\t\tif shouldExitOnReadlineError(err) {\n\t\t\tfmt.Println(\"\\nBye~!\")\n\t\t\treturn\n\t\t}\n\n\t\tif err != nil {\n\t\t\tfmt.Println(\"\\nBye~!\")\n\t\t\tbreak\n\t\t}\n\n\t\tinput = strings.TrimSpace(input)\n\t\tif input == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\targs := parseShellArgs(input)\n\t\tif len(args) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch args[0] {\n\t\tcase \"exit\", \"quit\":\n\t\t\tfmt.Println(\"Bye~!\")\n\t\t\treturn\n\t\tcase \"help\":\n\t\t\trootCmd.Help()\n\t\t\tcontinue\n\t\tcase \"clear\":\n\t\t\tclearScreen(os.Stdout)\n\t\t\tl.SetPrompt(promptForPath(currentPath))\n\t\t\tcontinue\n\t\tcase \"cd\":\n\t\t\tnextPath, err := changeDirectory(&p, currentPath, args[1:])\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(\"Change directory failed\")\n\t\t\t\tlogx.Error(err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcurrentPath = nextPath\n\t\t\tl.SetPrompt(promptForPath(currentPath))\n\t\t\tcontinue\n\t\tcase \"open\":\n\t\t\texpandedArgs, err := expandOpenGlobs(currentPath, &p, args[1:])\n\t\t\tif err != nil {\n\t\t\t\tfmt.Println(err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcmdCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)\n\t\t\tif err := handleOpenCommand(p.WithContext(cmdCtx), currentPath, expandedArgs); err != nil {\n\t\t\t\tfmt.Println(err.Error())\n\t\t\t}\n\t\t\tstop()\n\t\t\tif cmdCtx.Err() != nil {\n\t\t\t\tfmt.Println()\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\targs = adaptShellArgs(rootCmd, currentPath, args)\n\t\targs, err = expandShellGlobs(rootCmd, currentPath, &p, args)\n\t\tif err != nil {\n\t\t\tfmt.Println(err.Error())\n\t\t\tcontinue\n\t\t}\n\n\t\tcmdCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)\n\t\tsetCommandContextTree(rootCmd, cmdCtx)\n\t\trootCmd.SetArgs(args)\n\t\trootCmd.Execute()\n\t\tstop()\n\t\tsetCommandContextTree(rootCmd, context.Background())\n\t\trootCmd.SetArgs([]string{})\n\t\tresetFlags(rootCmd)\n\t\tif cmdCtx.Err() != nil {\n\t\t\tfmt.Println()\n\t\t}\n\t}\n}\n\nfunc shouldExitOnReadlineError(err error) bool {\n\treturn err == io.EOF\n}\n\nfunc isReadlineInterrupt(err error) bool {\n\treturn err == readline.ErrInterrupt\n}\n\nfunc setCommandContextTree(cmd *cobra.Command, ctx context.Context) {\n\tcmd.SetContext(ctx)\n\tfor _, child := range cmd.Commands() {\n\t\tsetCommandContextTree(child, ctx)\n\t}\n}\n\nfunc (c *shellAutoCompleter) Do(line []rune, pos int) ([][]rune, int) {\n\tinput := string(line[:pos])\n\ttokens, active, endedWithSpace := splitCompletionLine(input)\n\n\tif len(tokens) == 0 {\n\t\treturn completeFromPrefix(active, commandCandidates(c.rootCmd), true)\n\t}\n\n\tif tokens[0] == \"cd\" {\n\t\treturn c.completeRemotePath(active, true)\n\t}\n\tif tokens[0] == \"open\" {\n\t\treturn c.completeRemotePath(active, false)\n\t}\n\n\tcmd, consumed := resolveCommand(c.rootCmd, tokens)\n\n\tif consumed == 0 && !endedWithSpace {\n\t\treturn completeFromPrefix(active, commandCandidates(c.rootCmd), true)\n\t}\n\n\tif cmd == nil {\n\t\treturn nil, 0\n\t}\n\n\tif len(cmd.Commands()) > 0 && (endedWithSpace || active != \"\") && len(tokens) == consumed {\n\t\treturn completeFromPrefix(active, subcommandCandidates(cmd), true)\n\t}\n\n\tif strings.HasPrefix(active, \"-\") {\n\t\treturn completeFromPrefix(active, flagCandidates(cmd), true)\n\t}\n\n\tcommandKey := canonicalCommandKey(c.rootCmd, cmd)\n\tif shouldCompleteLocalPathFlagValue(commandKey, tokens, active, endedWithSpace) {\n\t\treturn completeLocalPath(active, false)\n\t}\n\tif shouldCompleteDirectoryPath(commandKey, tokens, active, endedWithSpace, consumed) {\n\t\treturn c.completeRemotePath(active, true)\n\t}\n\tif shouldCompleteRemoteTargetPath(commandKey, tokens, active, consumed) {\n\t\treturn c.completeRemotePath(active, false)\n\t}\n\tif shouldCompleteLocalTargetPath(commandKey, tokens, active, consumed) {\n\t\treturn completeLocalPath(active, false)\n\t}\n\n\treturn nil, 0\n}\n\nfunc shouldCompleteLocalPathFlagValue(commandKey string, tokens []string, active string, endedWithSpace bool) bool {\n\tif commandKey == \"\" {\n\t\treturn false\n\t}\n\n\tswitch commandKey {\n\tcase \"rubbish\":\n\t\treturn wantsFlagValue(tokens, active, endedWithSpace, \"--rules\")\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc shouldCompleteDirectoryPath(commandKey string, tokens []string, active string, endedWithSpace bool, consumed int) bool {\n\tif commandKey == \"\" {\n\t\treturn false\n\t}\n\n\tif wantsFlagValue(tokens, active, endedWithSpace, \"-p\", \"--path\") {\n\t\tswitch commandKey {\n\t\tcase \"ls\", \"empty\", \"rubbish\", \"download\", \"share\", \"upload\", \"delete\", \"new folder\", \"new url\", \"new sha\":\n\t\t\treturn true\n\t\t}\n\t}\n\n\tpositionalsAfterCommand := positionalTokens(tokens[consumed:], active)\n\n\tswitch commandKey {\n\tcase \"ls\", \"empty\", \"rubbish\":\n\t\treturn len(positionalsAfterCommand) <= 1\n\t}\n\n\treturn false\n}\n\nfunc shouldCompleteRemoteTargetPath(commandKey string, tokens []string, active string, consumed int) bool {\n\tif commandKey == \"\" || active == \"\" {\n\t\treturn false\n\t}\n\n\tpositionalsAfterCommand := positionalTokens(tokens[consumed:], active)\n\tswitch commandKey {\n\tcase \"download\", \"share\", \"delete\":\n\t\treturn len(positionalsAfterCommand) >= 1\n\tcase \"rename\":\n\t\treturn len(positionalsAfterCommand) == 1\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc shouldCompleteLocalTargetPath(commandKey string, tokens []string, active string, consumed int) bool {\n\tif commandKey == \"\" || active == \"\" {\n\t\treturn false\n\t}\n\n\tpositionalsAfterCommand := positionalTokens(tokens[consumed:], active)\n\tswitch commandKey {\n\tcase \"upload\":\n\t\treturn len(positionalsAfterCommand) >= 1\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc wantsFlagValue(tokens []string, active string, endedWithSpace bool, flags ...string) bool {\n\tif len(tokens) == 0 {\n\t\treturn false\n\t}\n\n\tlast := tokens[len(tokens)-1]\n\tif endedWithSpace {\n\t\treturn slices.Contains(flags, last)\n\t}\n\n\tif active != \"\" {\n\t\treturn slices.Contains(flags, last)\n\t}\n\n\treturn false\n}\n\nfunc positionalTokens(tokens []string, active string) []string {\n\tpositionals := make([]string, 0)\n\tstopFlags := false\n\n\tfor i := 0; i < len(tokens); i++ {\n\t\ttoken := tokens[i]\n\t\tif stopFlags {\n\t\t\tpositionals = append(positionals, token)\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\tcase token == \"-p\" || token == \"--path\" ||\n\t\t\ttoken == \"-P\" || token == \"--parent-id\" ||\n\t\t\ttoken == \"-o\" || token == \"--output\" ||\n\t\t\ttoken == \"-i\" || token == \"--input\" ||\n\t\t\ttoken == \"-c\" || token == \"--count\" ||\n\t\t\ttoken == \"--rules\":\n\t\t\tif i+1 < len(tokens) {\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"-\"):\n\t\tdefault:\n\t\t\tpositionals = append(positionals, token)\n\t\t}\n\t}\n\n\tif active != \"\" {\n\t\tpositionals = append(positionals, active)\n\t}\n\n\treturn positionals\n}\n\nfunc (c *shellAutoCompleter) completeRemotePath(prefix string, onlyDirs bool) ([][]rune, int) {\n\tcurrentPath := c.currentPath()\n\ttargetPath := resolveShellPath(currentPath, prefix)\n\tbasePrefix := prefix\n\tif strings.TrimSpace(prefix) == \"\" {\n\t\ttargetPath = currentPath\n\t\tbasePrefix = \"\"\n\t}\n\n\tparentPath := targetPath\n\tnamePrefix := \"\"\n\tif prefix != \"\" && !strings.HasSuffix(prefix, \"/\") {\n\t\tparentPath = path.Dir(targetPath)\n\t\tif parentPath == \".\" {\n\t\t\tparentPath = \"/\"\n\t\t}\n\t\tnamePrefix = path.Base(targetPath)\n\t}\n\n\tparentID := \"\"\n\tvar err error\n\tif parentPath != \"/\" {\n\t\tparentID, err = c.fileStatSource.GetPathFolderId(parentPath)\n\t\tif err != nil {\n\t\t\treturn nil, len([]rune(basePrefix))\n\t\t}\n\t}\n\n\tfiles, err := c.fileStatSource.GetFolderFileStatList(parentID)\n\tif err != nil {\n\t\treturn nil, len([]rune(basePrefix))\n\t}\n\n\tcandidates := make([]string, 0)\n\tfor _, file := range files {\n\t\tif onlyDirs && file.Kind != api.FileKindFolder {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(file.Name, namePrefix) {\n\t\t\tcontinue\n\t\t}\n\n\t\tremaining := file.Name[len(namePrefix):]\n\t\tif file.Kind == api.FileKindFolder {\n\t\t\tremaining += \"/\"\n\t\t}\n\t\tcandidates = append(candidates, escapeShellCompletion(remaining))\n\t}\n\n\treturn toRuneCandidates(candidates), len([]rune(basePrefix))\n}\n\nfunc completeLocalPath(prefix string, onlyDirs bool) ([][]rune, int) {\n\texpandedPrefix := utils.ExpandLocalPath(prefix)\n\tparentPath := \".\"\n\tbasePrefix := prefix\n\tnamePrefix := expandedPrefix\n\thasTrailingSeparator := strings.HasSuffix(prefix, string(filepath.Separator))\n\n\tif strings.TrimSpace(prefix) == \"\" {\n\t\tbasePrefix = \"\"\n\t\tnamePrefix = \"\"\n\t} else if !hasTrailingSeparator {\n\t\tparentPath = filepath.Dir(expandedPrefix)\n\t\tif parentPath == \".\" && filepath.IsAbs(expandedPrefix) {\n\t\t\tparentPath = string(filepath.Separator)\n\t\t}\n\t\tnamePrefix = filepath.Base(expandedPrefix)\n\t} else {\n\t\tparentPath = expandedPrefix\n\t\tnamePrefix = \"\"\n\t}\n\n\tentries, err := os.ReadDir(parentPath)\n\tif err != nil {\n\t\treturn nil, len([]rune(basePrefix))\n\t}\n\n\tcandidates := make([]string, 0)\n\tfor _, entry := range entries {\n\t\tif onlyDirs && !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(entry.Name(), namePrefix) {\n\t\t\tcontinue\n\t\t}\n\n\t\tremaining := entry.Name()[len(namePrefix):]\n\t\tif entry.IsDir() {\n\t\t\tremaining += string(filepath.Separator)\n\t\t}\n\t\tcandidates = append(candidates, escapeShellCompletion(remaining))\n\t}\n\n\treturn toRuneCandidates(candidates), len([]rune(basePrefix))\n}\n\nfunc promptForPath(currentPath string) string {\n\tif currentPath == \"/\" {\n\t\treturn \"pikpak / > \"\n\t}\n\treturn fmt.Sprintf(\"pikpak %s/ > \", currentPath)\n}\n\nfunc clearScreen(w io.Writer) {\n\tfmt.Fprint(w, clearScreenSequence)\n}\n\nfunc adaptShellArgs(rootCmd *cobra.Command, currentPath string, args []string) []string {\n\tif len(args) == 0 {\n\t\treturn args\n\t}\n\n\tcmd, consumed := resolveCommand(rootCmd, args)\n\tif consumed == 0 {\n\t\treturn args\n\t}\n\n\tcommandKey := canonicalCommandKey(rootCmd, cmd)\n\trest := append([]string{}, args[consumed:]...)\n\tflags := inspectShellArgs(rest)\n\n\tswitch commandKey {\n\tcase \"ls\", \"empty\", \"rubbish\":\n\t\trest = rewritePositionalPaths(rest, currentPath, 1)\n\t\tif flags.positionals == 0 && !flags.hasPath && !flags.hasParentID {\n\t\t\trest = append([]string{\"-p\", currentPath}, rest...)\n\t\t}\n\tcase \"download\":\n\t\trest = rewritePathFlagValues(rest, currentPath)\n\t\tif flags.positionals > 0 && !flags.hasPath && !flags.hasParentID {\n\t\t\trest = append([]string{\"-p\", currentPath}, rest...)\n\t\t}\n\tcase \"upload\":\n\t\trest = rewritePathFlagValues(rest, currentPath)\n\t\tif flags.positionals > 0 && !flags.hasPath && !flags.hasParentID {\n\t\t\trest = append([]string{\"-p\", currentPath}, rest...)\n\t\t}\n\tcase \"share\", \"new folder\", \"new url\", \"new sha\":\n\t\trest = rewritePathFlagValues(rest, currentPath)\n\t\tif !flags.hasPath && !flags.hasParentID {\n\t\t\trest = append([]string{\"-p\", currentPath}, rest...)\n\t\t}\n\tcase \"delete\":\n\t\tif !flags.hasPath {\n\t\t\trest = rewritePositionalPaths(rest, currentPath, -1)\n\t\t}\n\tcase \"rename\":\n\t\trest = rewritePositionalPaths(rest, currentPath, 1)\n\t}\n\n\treturn append(append([]string{}, args[:consumed]...), rest...)\n}\n\nfunc canonicalCommandKey(rootCmd *cobra.Command, cmd *cobra.Command) string {\n\tif cmd == nil {\n\t\treturn \"\"\n\t}\n\tpath := cmd.CommandPath()\n\trootName := rootCmd.Name()\n\tif path == rootName {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimPrefix(path, rootName+\" \")\n}\n\ntype shellArgFlags struct {\n\thasPath     bool\n\thasParentID bool\n\tpositionals int\n}\n\nfunc inspectShellArgs(args []string) shellArgFlags {\n\tvar flags shellArgFlags\n\tstopFlags := false\n\tfor i := 0; i < len(args); i++ {\n\t\ttoken := args[i]\n\t\tif stopFlags {\n\t\t\tflags.positionals++\n\t\t\tcontinue\n\t\t}\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\tcase token == \"--path\" || token == \"-p\":\n\t\t\tflags.hasPath = true\n\t\t\tif i+1 < len(args) {\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--path=\") || strings.HasPrefix(token, \"-p=\"):\n\t\t\tflags.hasPath = true\n\t\tcase token == \"--parent-id\" || token == \"-P\":\n\t\t\tflags.hasParentID = true\n\t\t\tif i+1 < len(args) {\n\t\t\t\ti++\n\t\t\t}\n\t\tcase token == \"--rules\":\n\t\t\tif i+1 < len(args) {\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--parent-id=\") || strings.HasPrefix(token, \"-P=\"):\n\t\t\tflags.hasParentID = true\n\t\tcase strings.HasPrefix(token, \"-\"):\n\t\tdefault:\n\t\t\tflags.positionals++\n\t\t}\n\t}\n\treturn flags\n}\n\nfunc rewritePathFlagValues(args []string, currentPath string) []string {\n\trewritten := append([]string{}, args...)\n\tfor i := 0; i < len(rewritten); i++ {\n\t\tswitch token := rewritten[i]; {\n\t\tcase token == \"--path\" || token == \"-p\":\n\t\t\tif i+1 < len(rewritten) {\n\t\t\t\trewritten[i+1] = resolveShellPath(currentPath, rewritten[i+1])\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--path=\"):\n\t\t\trewritten[i] = \"--path=\" + resolveShellPath(currentPath, strings.TrimPrefix(token, \"--path=\"))\n\t\tcase strings.HasPrefix(token, \"-p=\"):\n\t\t\trewritten[i] = \"-p=\" + resolveShellPath(currentPath, strings.TrimPrefix(token, \"-p=\"))\n\t\t}\n\t}\n\treturn rewritten\n}\n\nfunc rewritePositionalPaths(args []string, currentPath string, limit int) []string {\n\trewritten := append([]string{}, args...)\n\tstopFlags := false\n\trewrittenCount := 0\n\n\tfor i := 0; i < len(rewritten); i++ {\n\t\ttoken := rewritten[i]\n\t\tif stopFlags {\n\t\t\tif limit < 0 || rewrittenCount < limit {\n\t\t\t\trewritten[i] = resolveShellPath(currentPath, token)\n\t\t\t\trewrittenCount++\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\tcase token == \"--path\" || token == \"-p\" || token == \"--parent-id\" || token == \"-P\" || token == \"--output\" || token == \"-o\" || token == \"--input\" || token == \"-i\" || token == \"--count\" || token == \"-c\" || token == \"--rules\":\n\t\t\tif i+1 < len(rewritten) {\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"-\"):\n\t\tdefault:\n\t\t\tif limit >= 0 && rewrittenCount >= limit {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trewritten[i] = resolveShellPath(currentPath, token)\n\t\t\trewrittenCount++\n\t\t}\n\t}\n\n\treturn rewritten\n}\n\nfunc changeDirectory(p *api.PikPak, currentPath string, args []string) (string, error) {\n\ttarget := \"/\"\n\tif len(args) > 0 {\n\t\ttarget = args[0]\n\t}\n\n\ttargetPath := resolveShellPath(currentPath, target)\n\tif targetPath == \"/\" {\n\t\treturn targetPath, nil\n\t}\n\n\tif _, err := p.GetPathFolderId(targetPath); err != nil {\n\t\treturn \"\", fmt.Errorf(\"cd: %s: no such directory\", targetPath)\n\t}\n\n\treturn targetPath, nil\n}\n\nfunc resolveShellPath(currentPath string, target string) string {\n\tswitch strings.TrimSpace(target) {\n\tcase \"\", \"~\", \"/\":\n\t\treturn \"/\"\n\t}\n\n\tif strings.HasPrefix(target, \"/\") {\n\t\treturn path.Clean(target)\n\t}\n\n\treturn path.Clean(path.Join(currentPath, target))\n}\n\nfunc expandOpenGlobs(currentPath string, source fileStatProvider, args []string) ([]string, error) {\n\texpanded := make([]string, 0, len(args))\n\tfor _, arg := range args {\n\t\tmatches, err := expandRemotePatternToken(arg, \"\", currentPath, source, false)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\texpanded = append(expanded, matches...)\n\t}\n\treturn expanded, nil\n}\n\nfunc expandShellGlobs(rootCmd *cobra.Command, currentPath string, source fileStatProvider, args []string) ([]string, error) {\n\tif len(args) == 0 {\n\t\treturn args, nil\n\t}\n\n\tcmd, consumed := resolveCommand(rootCmd, args)\n\tif consumed == 0 {\n\t\treturn args, nil\n\t}\n\n\tcommandKey := canonicalCommandKey(rootCmd, cmd)\n\trest := append([]string{}, args[consumed:]...)\n\n\tvar (\n\t\texpanded []string\n\t\terr      error\n\t)\n\n\tswitch commandKey {\n\tcase \"download\":\n\t\texpanded, err = expandDownloadGlobs(rest, currentPath, source)\n\tcase \"delete\":\n\t\texpanded, err = expandDeleteGlobs(rest, currentPath, source)\n\tcase \"upload\":\n\t\texpanded, err = expandUploadGlobs(rest)\n\tdefault:\n\t\treturn args, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn append(append([]string{}, args[:consumed]...), expanded...), nil\n}\n\nfunc expandDownloadGlobs(args []string, currentPath string, source fileStatProvider) ([]string, error) {\n\treturn rewriteDownloadLikeArgs(args, currentPath, source)\n}\n\nfunc expandDeleteGlobs(args []string, currentPath string, source fileStatProvider) ([]string, error) {\n\trewritten := make([]string, 0, len(args))\n\tstopFlags := false\n\tpathValue := \"\"\n\n\tfor i := 0; i < len(args); i++ {\n\t\ttoken := args[i]\n\t\tif stopFlags {\n\t\t\tmatches, err := expandDeletePatternToken(token, pathValue, currentPath, source)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\t\trewritten = append(rewritten, token)\n\t\tcase token == \"--path\" || token == \"-p\":\n\t\t\trewritten = append(rewritten, token)\n\t\t\tif i+1 < len(args) {\n\t\t\t\tpathValue = args[i+1]\n\t\t\t\trewritten = append(rewritten, pathValue)\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--path=\"):\n\t\t\tpathValue = strings.TrimPrefix(token, \"--path=\")\n\t\t\trewritten = append(rewritten, token)\n\t\tcase strings.HasPrefix(token, \"-p=\"):\n\t\t\tpathValue = strings.TrimPrefix(token, \"-p=\")\n\t\t\trewritten = append(rewritten, token)\n\t\tdefault:\n\t\t\tif consumesNextValue(token) {\n\t\t\t\trewritten = append(rewritten, token)\n\t\t\t\tif i+1 < len(args) {\n\t\t\t\t\trewritten = append(rewritten, args[i+1])\n\t\t\t\t\ti++\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.HasPrefix(token, \"-\") {\n\t\t\t\trewritten = append(rewritten, token)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmatches, err := expandDeletePatternToken(token, pathValue, currentPath, source)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t}\n\t}\n\n\treturn rewritten, nil\n}\n\nfunc expandUploadGlobs(args []string) ([]string, error) {\n\trewritten := make([]string, 0, len(args))\n\tstopFlags := false\n\n\tfor i := 0; i < len(args); i++ {\n\t\ttoken := args[i]\n\t\tif stopFlags {\n\t\t\tmatches, err := expandLocalPatternToken(token)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\t\trewritten = append(rewritten, token)\n\t\tcase token == \"--path\" || token == \"-p\" ||\n\t\t\ttoken == \"--parent-id\" || token == \"-P\" ||\n\t\t\ttoken == \"--concurrency\" || token == \"-c\" ||\n\t\t\ttoken == \"--exn\" || token == \"-e\":\n\t\t\trewritten = append(rewritten, token)\n\t\t\tif i+1 < len(args) {\n\t\t\t\trewritten = append(rewritten, args[i+1])\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--path=\") ||\n\t\t\tstrings.HasPrefix(token, \"-p=\") ||\n\t\t\tstrings.HasPrefix(token, \"--parent-id=\") ||\n\t\t\tstrings.HasPrefix(token, \"-P=\") ||\n\t\t\tstrings.HasPrefix(token, \"--concurrency=\") ||\n\t\t\tstrings.HasPrefix(token, \"-c=\") ||\n\t\t\tstrings.HasPrefix(token, \"--exn=\") ||\n\t\t\tstrings.HasPrefix(token, \"-e=\") ||\n\t\t\tstrings.HasPrefix(token, \"-\"):\n\t\t\trewritten = append(rewritten, token)\n\t\tdefault:\n\t\t\tmatches, err := expandLocalPatternToken(token)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t}\n\t}\n\n\treturn rewritten, nil\n}\n\nfunc rewriteDownloadLikeArgs(args []string, currentPath string, source fileStatProvider) ([]string, error) {\n\trewritten := make([]string, 0, len(args))\n\tstopFlags := false\n\tpathValue := \"\"\n\thasParentID := false\n\n\tfor i := 0; i < len(args); i++ {\n\t\ttoken := args[i]\n\t\tif stopFlags {\n\t\t\tmatches, err := expandRemotePatternToken(token, pathValue, currentPath, source, true)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase token == \"--\":\n\t\t\tstopFlags = true\n\t\t\trewritten = append(rewritten, token)\n\t\tcase token == \"--path\" || token == \"-p\":\n\t\t\trewritten = append(rewritten, token)\n\t\t\tif i+1 < len(args) {\n\t\t\t\tpathValue = args[i+1]\n\t\t\t\trewritten = append(rewritten, pathValue)\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--path=\"):\n\t\t\tpathValue = strings.TrimPrefix(token, \"--path=\")\n\t\t\trewritten = append(rewritten, token)\n\t\tcase strings.HasPrefix(token, \"-p=\"):\n\t\t\tpathValue = strings.TrimPrefix(token, \"-p=\")\n\t\t\trewritten = append(rewritten, token)\n\t\tcase token == \"--parent-id\" || token == \"-P\":\n\t\t\thasParentID = true\n\t\t\trewritten = append(rewritten, token)\n\t\t\tif i+1 < len(args) {\n\t\t\t\trewritten = append(rewritten, args[i+1])\n\t\t\t\ti++\n\t\t\t}\n\t\tcase strings.HasPrefix(token, \"--parent-id=\") || strings.HasPrefix(token, \"-P=\"):\n\t\t\thasParentID = true\n\t\t\trewritten = append(rewritten, token)\n\t\tdefault:\n\t\t\tif consumesNextValue(token) {\n\t\t\t\trewritten = append(rewritten, token)\n\t\t\t\tif i+1 < len(args) {\n\t\t\t\t\trewritten = append(rewritten, args[i+1])\n\t\t\t\t\ti++\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.HasPrefix(token, \"-\") {\n\t\t\t\trewritten = append(rewritten, token)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif hasParentID && pathValue == \"\" && hasWildcard(token) {\n\t\t\t\treturn nil, fmt.Errorf(\"shell: wildcard expansion with --parent-id requires --path\")\n\t\t\t}\n\t\t\tmatches, err := expandRemotePatternToken(token, pathValue, currentPath, source, true)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\trewritten = append(rewritten, matches...)\n\t\t}\n\t}\n\n\treturn rewritten, nil\n}\n\nfunc expandDeletePatternToken(token string, pathValue string, currentPath string, source fileStatProvider) ([]string, error) {\n\tif !hasWildcard(token) {\n\t\treturn []string{token}, nil\n\t}\n\tif pathValue != \"\" && !path.IsAbs(token) && strings.Contains(token, \"/\") {\n\t\treturn nil, fmt.Errorf(\"shell: wildcard expansion with -p does not support nested remote paths: %s\", token)\n\t}\n\treturn expandRemotePatternToken(token, pathValue, currentPath, source, pathValue != \"\")\n}\n\nfunc expandRemotePatternToken(token string, pathValue string, currentPath string, source fileStatProvider, preferRelative bool) ([]string, error) {\n\tif !hasWildcard(token) {\n\t\treturn []string{token}, nil\n\t}\n\n\tbasePath := currentPath\n\tif strings.TrimSpace(pathValue) != \"\" {\n\t\tbasePath = pathValue\n\t}\n\n\tpatternPath := token\n\tif !path.IsAbs(patternPath) {\n\t\tpatternPath = path.Clean(path.Join(basePath, patternPath))\n\t} else {\n\t\tpatternPath = path.Clean(patternPath)\n\t}\n\n\tparentPath := path.Dir(patternPath)\n\tif parentPath == \".\" {\n\t\tparentPath = \"/\"\n\t}\n\n\tmatches, err := matchRemotePattern(source, parentPath, path.Base(patternPath))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"shell: no matches found for %s\", token)\n\t}\n\n\tif preferRelative && !path.IsAbs(token) && strings.TrimSpace(pathValue) != \"\" {\n\t\trewritten := make([]string, 0, len(matches))\n\t\tfor _, match := range matches {\n\t\t\trewritten = append(rewritten, relativeRemotePath(pathValue, match))\n\t\t}\n\t\treturn rewritten, nil\n\t}\n\n\treturn matches, nil\n}\n\nfunc matchRemotePattern(source fileStatProvider, parentPath string, pattern string) ([]string, error) {\n\tparentID := \"\"\n\tif parentPath != \"/\" {\n\t\tvar err error\n\t\tparentID, err = source.GetPathFolderId(parentPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfiles, err := source.GetFolderFileStatList(parentID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmatches := make([]string, 0)\n\tfor _, file := range files {\n\t\tmatched, err := path.Match(pattern, file.Name)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"shell: invalid wildcard pattern %s: %w\", pattern, err)\n\t\t}\n\t\tif matched {\n\t\t\tmatches = append(matches, path.Join(parentPath, file.Name))\n\t\t}\n\t}\n\treturn matches, nil\n}\n\nfunc expandLocalPatternToken(token string) ([]string, error) {\n\tif !hasWildcard(token) {\n\t\treturn []string{token}, nil\n\t}\n\n\tpattern := utils.ExpandLocalPath(token)\n\tmatches, err := filepath.Glob(pattern)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"shell: invalid wildcard pattern %s: %w\", token, err)\n\t}\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"shell: no matches found for %s\", token)\n\t}\n\treturn matches, nil\n}\n\nfunc consumesNextValue(token string) bool {\n\tswitch token {\n\tcase \"--path\", \"-p\",\n\t\t\"--parent-id\", \"-P\",\n\t\t\"--output\", \"-o\",\n\t\t\"--input\", \"-i\",\n\t\t\"--count\", \"-c\",\n\t\t\"--rules\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc hasWildcard(value string) bool {\n\treturn strings.ContainsAny(value, \"*?[\")\n}\n\nfunc relativeRemotePath(basePath string, fullPath string) string {\n\tbase := path.Clean(basePath)\n\tfull := path.Clean(fullPath)\n\tif base == \"/\" {\n\t\treturn strings.TrimPrefix(full, \"/\")\n\t}\n\tprefix := base + \"/\"\n\tif strings.HasPrefix(full, prefix) {\n\t\treturn strings.TrimPrefix(full, prefix)\n\t}\n\treturn full\n}\n\nfunc splitCompletionLine(input string) ([]string, string, bool) {\n\targs := make([]string, 0)\n\tvar current strings.Builder\n\tinDoubleQuote := false\n\tinSingleQuote := false\n\tendedWithSpace := false\n\tescaped := false\n\n\tfor i := 0; i < len(input); i++ {\n\t\tch := input[i]\n\n\t\tif escaped {\n\t\t\tcurrent.WriteByte(ch)\n\t\t\tescaped = false\n\t\t\tendedWithSpace = false\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch ch {\n\t\tcase '\\\\':\n\t\t\tif inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tescaped = true\n\t\t\t}\n\t\t\tendedWithSpace = false\n\t\tcase '\"':\n\t\t\tendedWithSpace = false\n\t\t\tif inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tinDoubleQuote = !inDoubleQuote\n\t\t\t}\n\t\tcase '\\'':\n\t\t\tendedWithSpace = false\n\t\t\tif inDoubleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tinSingleQuote = !inSingleQuote\n\t\t\t}\n\t\tcase ' ', '\\t':\n\t\t\tif inDoubleQuote || inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t\tendedWithSpace = false\n\t\t\t} else {\n\t\t\t\tif current.Len() > 0 {\n\t\t\t\t\targs = append(args, current.String())\n\t\t\t\t\tcurrent.Reset()\n\t\t\t\t}\n\t\t\t\tendedWithSpace = true\n\t\t\t}\n\t\tdefault:\n\t\t\tcurrent.WriteByte(ch)\n\t\t\tendedWithSpace = false\n\t\t}\n\t}\n\n\tif current.Len() > 0 {\n\t\treturn args, current.String(), false\n\t}\n\n\treturn args, \"\", endedWithSpace\n}\n\nfunc commandCandidates(rootCmd *cobra.Command) []string {\n\tcandidates := append([]string{}, builtInCommands...)\n\tcandidates = append(candidates, subcommandCandidates(rootCmd)...)\n\tslices.Sort(candidates)\n\treturn slices.Compact(candidates)\n}\n\nfunc subcommandCandidates(cmd *cobra.Command) []string {\n\tcandidates := make([]string, 0)\n\tfor _, sub := range cmd.Commands() {\n\t\tif sub.Hidden {\n\t\t\tcontinue\n\t\t}\n\t\tcandidates = append(candidates, sub.Name())\n\t\tcandidates = append(candidates, sub.Aliases...)\n\t}\n\tslices.Sort(candidates)\n\treturn slices.Compact(candidates)\n}\n\nfunc flagCandidates(cmd *cobra.Command) []string {\n\tcandidates := make([]string, 0)\n\tcmd.Flags().VisitAll(func(f *pflag.Flag) {\n\t\tcandidates = append(candidates, \"--\"+f.Name)\n\t\tif f.Shorthand != \"\" {\n\t\t\tcandidates = append(candidates, \"-\"+f.Shorthand)\n\t\t}\n\t})\n\tcmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {\n\t\tcandidates = append(candidates, \"--\"+f.Name)\n\t\tif f.Shorthand != \"\" {\n\t\t\tcandidates = append(candidates, \"-\"+f.Shorthand)\n\t\t}\n\t})\n\tslices.Sort(candidates)\n\treturn slices.Compact(candidates)\n}\n\nfunc resolveCommand(rootCmd *cobra.Command, tokens []string) (*cobra.Command, int) {\n\tcurrent := rootCmd\n\tconsumed := 0\n\n\tfor _, token := range tokens {\n\t\tmatched := false\n\t\tfor _, sub := range current.Commands() {\n\t\t\tif sub.Hidden {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif token == sub.Name() || slices.Contains(sub.Aliases, token) {\n\t\t\t\tcurrent = sub\n\t\t\t\tconsumed++\n\t\t\t\tmatched = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !matched {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn current, consumed\n}\n\nfunc completeFromPrefix(prefix string, candidates []string, appendSpace bool) ([][]rune, int) {\n\tmatches := make([]string, 0)\n\tfor _, candidate := range candidates {\n\t\tif !strings.HasPrefix(candidate, prefix) {\n\t\t\tcontinue\n\t\t}\n\t\tsuffix := candidate[len(prefix):]\n\t\tif appendSpace {\n\t\t\tsuffix += \" \"\n\t\t}\n\t\tmatches = append(matches, suffix)\n\t}\n\treturn toRuneCandidates(matches), len([]rune(prefix))\n}\n\nfunc toRuneCandidates(candidates []string) [][]rune {\n\tout := make([][]rune, 0, len(candidates))\n\tfor _, candidate := range candidates {\n\t\tout = append(out, []rune(candidate))\n\t}\n\treturn out\n}\n\n// parseShellArgs parses shell-like arguments\nfunc parseShellArgs(input string) []string {\n\tvar args []string\n\tvar current strings.Builder\n\tinDoubleQuote := false\n\tinSingleQuote := false\n\tescaped := false\n\n\tfor i := 0; i < len(input); i++ {\n\t\tch := input[i]\n\n\t\tif escaped {\n\t\t\tcurrent.WriteByte(ch)\n\t\t\tescaped = false\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch ch {\n\t\tcase '\\\\':\n\t\t\tif inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tescaped = true\n\t\t\t}\n\t\tcase '\"':\n\t\t\tif inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tinDoubleQuote = !inDoubleQuote\n\t\t\t}\n\t\tcase '\\'':\n\t\t\tif inDoubleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else {\n\t\t\t\tinSingleQuote = !inSingleQuote\n\t\t\t}\n\t\tcase ' ', '\\t':\n\t\t\tif inDoubleQuote || inSingleQuote {\n\t\t\t\tcurrent.WriteByte(ch)\n\t\t\t} else if current.Len() > 0 {\n\t\t\t\targs = append(args, current.String())\n\t\t\t\tcurrent.Reset()\n\t\t\t}\n\t\tdefault:\n\t\t\tcurrent.WriteByte(ch)\n\t\t}\n\t}\n\n\tif current.Len() > 0 {\n\t\targs = append(args, current.String())\n\t}\n\treturn args\n}\n\nfunc escapeShellCompletion(value string) string {\n\tvar escaped strings.Builder\n\tescaped.Grow(len(value))\n\tfor i := 0; i < len(value); i++ {\n\t\tswitch value[i] {\n\t\tcase ' ', '\\\\', '\"', '\\'':\n\t\t\tescaped.WriteByte('\\\\')\n\t\t}\n\t\tescaped.WriteByte(value[i])\n\t}\n\treturn escaped.String()\n}\n\n// resetFlags recursively resets all flags in the command tree to their default values\nfunc resetFlags(cmd *cobra.Command) {\n\tcmd.Flags().VisitAll(func(f *pflag.Flag) {\n\t\tf.Value.Set(f.DefValue)\n\t})\n\tcmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {\n\t\tf.Value.Set(f.DefValue)\n\t})\n\tfor _, subCmd := range cmd.Commands() {\n\t\tresetFlags(subCmd)\n\t}\n}\n"
  },
  {
    "path": "internal/shell/shell_test.go",
    "content": "package shell\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/internal/api\"\n\t\"github.com/chzyer/readline\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestParseShellArgs(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput string\n\t\twant  []string\n\t}{\n\t\t{\n\t\t\tname:  \"plain args\",\n\t\t\tinput: \"ls -l -p /Movies\",\n\t\t\twant:  []string{\"ls\", \"-l\", \"-p\", \"/Movies\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"double quoted path\",\n\t\t\tinput: `cd \"/Movies/Kids Cartoons\"`,\n\t\t\twant:  []string{\"cd\", \"/Movies/Kids Cartoons\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"single quoted path\",\n\t\t\tinput: \"cd '/Movies/Kids Cartoons'\",\n\t\t\twant:  []string{\"cd\", \"/Movies/Kids Cartoons\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"escaped spaces\",\n\t\t\tinput: `cd /My\\ Pack/Kids\\ Cartoons`,\n\t\t\twant:  []string{\"cd\", \"/My Pack/Kids Cartoons\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trequire.Equal(t, tt.want, parseShellArgs(tt.input))\n\t\t})\n\t}\n}\n\nfunc TestResolveShellPath(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcurrentPath string\n\t\ttarget      string\n\t\twant        string\n\t}{\n\t\t{\n\t\t\tname:        \"root home shortcut\",\n\t\t\tcurrentPath: \"/Movies\",\n\t\t\ttarget:      \"~\",\n\t\t\twant:        \"/\",\n\t\t},\n\t\t{\n\t\t\tname:        \"relative child\",\n\t\t\tcurrentPath: \"/Movies\",\n\t\t\ttarget:      \"Kids\",\n\t\t\twant:        \"/Movies/Kids\",\n\t\t},\n\t\t{\n\t\t\tname:        \"relative parent\",\n\t\t\tcurrentPath: \"/Movies/Kids\",\n\t\t\ttarget:      \"..\",\n\t\t\twant:        \"/Movies\",\n\t\t},\n\t\t{\n\t\t\tname:        \"absolute path\",\n\t\t\tcurrentPath: \"/Movies\",\n\t\t\ttarget:      \"/TV Shows/Drama\",\n\t\t\twant:        \"/TV Shows/Drama\",\n\t\t},\n\t\t{\n\t\t\tname:        \"clean repeated separators\",\n\t\t\tcurrentPath: \"/Movies\",\n\t\t\ttarget:      \"Kids//Cartoons\",\n\t\t\twant:        \"/Movies/Kids/Cartoons\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty target goes root\",\n\t\t\tcurrentPath: \"/Movies/Kids\",\n\t\t\ttarget:      \"\",\n\t\t\twant:        \"/\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trequire.Equal(t, tt.want, resolveShellPath(tt.currentPath, tt.target))\n\t\t})\n\t}\n}\n\nfunc TestSplitCompletionLine(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tinput  string\n\t\ttokens []string\n\t\tactive string\n\t\tspaced bool\n\t}{\n\t\t{\n\t\t\tname:   \"partial command\",\n\t\t\tinput:  \"sh\",\n\t\t\ttokens: []string{},\n\t\t\tactive: \"sh\",\n\t\t},\n\t\t{\n\t\t\tname:   \"command with trailing space\",\n\t\t\tinput:  \"cd \",\n\t\t\ttokens: []string{\"cd\"},\n\t\t\tactive: \"\",\n\t\t\tspaced: true,\n\t\t},\n\t\t{\n\t\t\tname:   \"quoted path\",\n\t\t\tinput:  `cd \"/Movies/Kids Cartoons`,\n\t\t\ttokens: []string{\"cd\"},\n\t\t\tactive: \"/Movies/Kids Cartoons\",\n\t\t},\n\t\t{\n\t\t\tname:   \"escaped spaces\",\n\t\t\tinput:  `cd /My\\ Pack/Kids\\ Cart`,\n\t\t\ttokens: []string{\"cd\"},\n\t\t\tactive: \"/My Pack/Kids Cart\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttokens, active, spaced := splitCompletionLine(tt.input)\n\t\t\trequire.Equal(t, tt.tokens, tokens)\n\t\t\trequire.Equal(t, tt.active, active)\n\t\t\trequire.Equal(t, tt.spaced, spaced)\n\t\t})\n\t}\n}\n\nfunc TestShouldExitOnReadlineError(t *testing.T) {\n\trequire.True(t, shouldExitOnReadlineError(io.EOF))\n\trequire.False(t, shouldExitOnReadlineError(nil))\n\trequire.False(t, shouldExitOnReadlineError(readline.ErrInterrupt))\n\trequire.False(t, shouldExitOnReadlineError(errors.New(\"other error\")))\n}\n\nfunc TestIsReadlineInterrupt(t *testing.T) {\n\trequire.True(t, isReadlineInterrupt(readline.ErrInterrupt))\n\trequire.False(t, isReadlineInterrupt(nil))\n\trequire.False(t, isReadlineInterrupt(io.EOF))\n}\n\nfunc TestSetCommandContextTree(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"root\"}\n\tchildCmd := &cobra.Command{Use: \"child\"}\n\trootCmd.AddCommand(childCmd)\n\n\tctx1, cancel1 := context.WithCancel(context.Background())\n\tsetCommandContextTree(rootCmd, ctx1)\n\tcancel1()\n\n\trequire.ErrorIs(t, rootCmd.Context().Err(), context.Canceled)\n\trequire.ErrorIs(t, childCmd.Context().Err(), context.Canceled)\n\n\tctx2 := context.Background()\n\tsetCommandContextTree(rootCmd, ctx2)\n\n\trequire.NoError(t, rootCmd.Context().Err())\n\trequire.NoError(t, childCmd.Context().Err())\n}\n\nfunc TestCompleterCommandsAndFlags(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tlistCmd := &cobra.Command{Use: \"ls\"}\n\tlistCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(listCmd)\n\temptyCmd := &cobra.Command{Use: \"empty\"}\n\temptyCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(emptyCmd)\n\tdownloadCmd := &cobra.Command{Use: \"download\"}\n\tdownloadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(downloadCmd)\n\tshareCmd := &cobra.Command{Use: \"share\"}\n\tshareCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(shareCmd)\n\trubbishCmd := &cobra.Command{Use: \"rubbish\"}\n\trubbishCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trubbishCmd.Flags().String(\"rules\", \"\", \"\")\n\trootCmd.AddCommand(rubbishCmd)\n\tdeleteCmd := &cobra.Command{Use: \"delete\"}\n\tdeleteCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(deleteCmd)\n\trenameCmd := &cobra.Command{Use: \"rename\"}\n\trootCmd.AddCommand(renameCmd)\n\trootCmd.AddCommand(&cobra.Command{Use: \"shell\"})\n\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd: rootCmd,\n\t\tfileStatSource: fakeFileStatProvider{\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"\": {\n\t\t\t\t\t{Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t\t\t{Name: \"Music\", Kind: api.FileKindFolder},\n\t\t\t\t\t{Name: \"movie.mp4\", Kind: api.FileKindFile},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"sh\"), 2)\n\trequire.Equal(t, 2, offset)\n\trequire.Contains(t, candidates, []rune(\"ell \"))\n\trequire.Contains(t, commandCandidates(rootCmd), \"clear\")\n\trequire.Contains(t, commandCandidates(rootCmd), \"open\")\n\n\tcandidates, offset = completer.Do([]rune(\"ls -\"), 4)\n\trequire.Equal(t, 1, offset)\n\trequire.Contains(t, candidates, []rune(\"p \"))\n\n\tcandidates, offset = completer.Do([]rune(\"ls /Mov\"), len(\"ls /Mov\"))\n\trequire.Equal(t, len([]rune(\"/Mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ies/\"))\n\n\tcandidates, offset = completer.Do([]rune(\"empty -p /Mov\"), len(\"empty -p /Mov\"))\n\trequire.Equal(t, len([]rune(\"/Mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ies/\"))\n\n\tcandidates, offset = completer.Do([]rune(\"download -p /Mov\"), len(\"download -p /Mov\"))\n\trequire.Equal(t, len([]rune(\"/Mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ies/\"))\n\n\tcandidates, offset = completer.Do([]rune(\"download mov\"), len(\"download mov\"))\n\trequire.Equal(t, len([]rune(\"mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ie.mp4\"))\n\n\tcandidates, offset = completer.Do([]rune(\"share mov\"), len(\"share mov\"))\n\trequire.Equal(t, len([]rune(\"mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ie.mp4\"))\n\n\tcandidates, offset = completer.Do([]rune(\"delete mov\"), len(\"delete mov\"))\n\trequire.Equal(t, len([]rune(\"mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ie.mp4\"))\n\n\tcandidates, offset = completer.Do([]rune(\"rename mov\"), len(\"rename mov\"))\n\trequire.Equal(t, len([]rune(\"mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ie.mp4\"))\n\n\tcandidates, offset = completer.Do([]rune(\"open mov\"), len(\"open mov\"))\n\trequire.Equal(t, len([]rune(\"mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ie.mp4\"))\n}\n\nfunc TestCompleterUploadLocalPath(t *testing.T) {\n\ttempDir := t.TempDir()\n\trequire.NoError(t, os.WriteFile(filepath.Join(tempDir, \"local.txt\"), []byte(\"x\"), 0644))\n\trequire.NoError(t, os.Mkdir(filepath.Join(tempDir, \"folder\"), 0755))\n\n\toldWD, err := os.Getwd()\n\trequire.NoError(t, err)\n\trequire.NoError(t, os.Chdir(tempDir))\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(oldWD)\n\t})\n\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tuploadCmd := &cobra.Command{Use: \"upload\"}\n\tuploadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(uploadCmd)\n\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd:        rootCmd,\n\t\tfileStatSource: fakeFileStatProvider{},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"upload loc\"), len(\"upload loc\"))\n\trequire.Equal(t, len([]rune(\"loc\")), offset)\n\trequire.Contains(t, candidates, []rune(\"al.txt\"))\n\n\tcandidates, offset = completer.Do([]rune(\"upload fol\"), len(\"upload fol\"))\n\trequire.Equal(t, len([]rune(\"fol\")), offset)\n\trequire.Contains(t, candidates, []rune(\"der/\"))\n}\n\nfunc TestCompleterUploadHomePath(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\ttempHomeRoot := filepath.Dir(home)\n\thomeName := filepath.Base(home)\n\ttestDirName := \"codex-upload-home-test\"\n\ttestDir := filepath.Join(home, testDirName)\n\trequire.NoError(t, os.MkdirAll(testDir, 0755))\n\tt.Cleanup(func() {\n\t\t_ = os.RemoveAll(testDir)\n\t})\n\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tuploadCmd := &cobra.Command{Use: \"upload\"}\n\tuploadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(uploadCmd)\n\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd:        rootCmd,\n\t\tfileStatSource: fakeFileStatProvider{},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\toldWD, err := os.Getwd()\n\trequire.NoError(t, err)\n\trequire.NoError(t, os.Chdir(tempHomeRoot))\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(oldWD)\n\t})\n\n\tcandidates, offset := completer.Do([]rune(\"upload ~/\"+testDirName[:5]), len(\"upload ~/\"+testDirName[:5]))\n\trequire.Equal(t, len([]rune(\"~/\"+testDirName[:5])), offset)\n\trequire.Contains(t, candidates, []rune(testDirName[5:]+\"/\"))\n\trequire.NotEmpty(t, homeName)\n\n\tcandidates, offset = completer.Do([]rune(\"upload ~/\"), len(\"upload ~/\"))\n\trequire.Equal(t, len([]rune(\"~/\")), offset)\n\trequire.Contains(t, candidates, []rune(testDirName+\"/\"))\n}\n\nfunc TestCompleterRubbishRulesLocalThenRemotePath(t *testing.T) {\n\ttempDir := t.TempDir()\n\trequire.NoError(t, os.WriteFile(filepath.Join(tempDir, \"rubbish_rules.txt\"), []byte(\"*.tmp\\n\"), 0644))\n\n\toldWD, err := os.Getwd()\n\trequire.NoError(t, err)\n\trequire.NoError(t, os.Chdir(tempDir))\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(oldWD)\n\t})\n\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\trubbishCmd := &cobra.Command{Use: \"rubbish\"}\n\trubbishCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trubbishCmd.Flags().String(\"rules\", \"\", \"\")\n\trootCmd.AddCommand(rubbishCmd)\n\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd: rootCmd,\n\t\tfileStatSource: fakeFileStatProvider{\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"\": {\n\t\t\t\t\t{Name: \"My Pack\", Kind: api.FileKindFolder},\n\t\t\t\t\t{Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"rubbish --rules rub\"), len(\"rubbish --rules rub\"))\n\trequire.Equal(t, len([]rune(\"rub\")), offset)\n\trequire.Contains(t, candidates, []rune(\"bish_rules.txt\"))\n\n\tcandidates, offset = completer.Do([]rune(\"rubbish --rules rubbish_rules.txt /My\"), len(\"rubbish --rules rubbish_rules.txt /My\"))\n\trequire.Equal(t, len([]rune(\"/My\")), offset)\n\trequire.Contains(t, candidates, []rune(\"\\\\ Pack/\"))\n}\n\nfunc TestClearScreen(t *testing.T) {\n\tvar out strings.Builder\n\tclearScreen(&out)\n\trequire.Equal(t, clearScreenSequence, out.String())\n}\n\nfunc TestAdaptShellArgs(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\n\tlistCmd := &cobra.Command{Use: \"ls\"}\n\tlistCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(listCmd)\n\n\temptyCmd := &cobra.Command{Use: \"empty\"}\n\temptyCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(emptyCmd)\n\n\tdownloadCmd := &cobra.Command{Use: \"download\"}\n\tdownloadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tdownloadCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\trootCmd.AddCommand(downloadCmd)\n\n\tshareCmd := &cobra.Command{Use: \"share\"}\n\tshareCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tshareCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\trootCmd.AddCommand(shareCmd)\n\n\tuploadCmd := &cobra.Command{Use: \"upload\"}\n\tuploadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tuploadCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\trootCmd.AddCommand(uploadCmd)\n\n\tdeleteCmd := &cobra.Command{Use: \"delete\"}\n\tdeleteCmd.Aliases = []string{\"del\", \"rm\"}\n\tdeleteCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(deleteCmd)\n\n\trenameCmd := &cobra.Command{Use: \"rename\"}\n\trootCmd.AddCommand(renameCmd)\n\n\tnewCmd := &cobra.Command{Use: \"new\"}\n\tnewCmd.Aliases = []string{\"n\"}\n\tnewFolderCmd := &cobra.Command{Use: \"folder\"}\n\tnewFolderCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tnewFolderCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\tnewCmd.AddCommand(newFolderCmd)\n\tnewURLCmd := &cobra.Command{Use: \"url\"}\n\tnewURLCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tnewURLCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\tnewCmd.AddCommand(newURLCmd)\n\tnewSHACmd := &cobra.Command{Use: \"sha\"}\n\tnewSHACmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tnewSHACmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\tnewCmd.AddCommand(newSHACmd)\n\trootCmd.AddCommand(newCmd)\n\n\ttests := []struct {\n\t\tname string\n\t\targs []string\n\t\twant []string\n\t}{\n\t\t{name: \"ls injects current path\", args: []string{\"ls\"}, want: []string{\"ls\", \"-p\", \"/Movies\"}},\n\t\t{name: \"ls rewrites relative arg\", args: []string{\"ls\", \"Kids\"}, want: []string{\"ls\", \"/Movies/Kids\"}},\n\t\t{name: \"empty rewrites relative arg\", args: []string{\"empty\", \"Kids\"}, want: []string{\"empty\", \"/Movies/Kids\"}},\n\t\t{name: \"rubbish keeps local rules path and rewrites remote root\", args: []string{\"rubbish\", \"--rules\", \"~/Library/Application Support/pikpakcli/rules/rubbish_rules.txt\", \"/\"}, want: []string{\"rubbish\", \"--rules\", \"~/Library/Application Support/pikpakcli/rules/rubbish_rules.txt\", \"/\"}},\n\t\t{name: \"download without args keeps command unchanged\", args: []string{\"download\"}, want: []string{\"download\"}},\n\t\t{name: \"download injects current path\", args: []string{\"download\", \"episode.mkv\"}, want: []string{\"download\", \"-p\", \"/Movies\", \"episode.mkv\"}},\n\t\t{name: \"download rewrites relative path flag\", args: []string{\"download\", \"-p\", \"Kids\", \"episode.mkv\"}, want: []string{\"download\", \"-p\", \"/Movies/Kids\", \"episode.mkv\"}},\n\t\t{name: \"download keeps trailing dot as positional target\", args: []string{\"download\", \"-g\", \"episode.mkv\", \".\"}, want: []string{\"download\", \"-p\", \"/Movies\", \"-g\", \"episode.mkv\", \".\"}},\n\t\t{name: \"share injects current path\", args: []string{\"share\", \"episode.mkv\"}, want: []string{\"share\", \"-p\", \"/Movies\", \"episode.mkv\"}},\n\t\t{name: \"upload without args keeps command unchanged\", args: []string{\"upload\"}, want: []string{\"upload\"}},\n\t\t{name: \"upload injects current path\", args: []string{\"upload\", \"local.file\"}, want: []string{\"upload\", \"-p\", \"/Movies\", \"local.file\"}},\n\t\t{name: \"delete rewrites relative args\", args: []string{\"delete\", \"a\", \"b/c\"}, want: []string{\"delete\", \"/Movies/a\", \"/Movies/b/c\"}},\n\t\t{name: \"rm alias rewrites relative args\", args: []string{\"rm\", \"a\", \"b/c\"}, want: []string{\"rm\", \"/Movies/a\", \"/Movies/b/c\"}},\n\t\t{name: \"rename rewrites first arg only\", args: []string{\"rename\", \"old.txt\", \"new.txt\"}, want: []string{\"rename\", \"/Movies/old.txt\", \"new.txt\"}},\n\t\t{name: \"new folder injects current path\", args: []string{\"new\", \"folder\", \"a/b\"}, want: []string{\"new\", \"folder\", \"-p\", \"/Movies\", \"a/b\"}},\n\t\t{name: \"new alias folder injects current path\", args: []string{\"n\", \"folder\", \"a/b\"}, want: []string{\"n\", \"folder\", \"-p\", \"/Movies\", \"a/b\"}},\n\t\t{name: \"new url injects current path\", args: []string{\"new\", \"url\", \"https://example.com\"}, want: []string{\"new\", \"url\", \"-p\", \"/Movies\", \"https://example.com\"}},\n\t\t{name: \"new sha injects current path\", args: []string{\"new\", \"sha\", \"PikPak://a|1|sha\"}, want: []string{\"new\", \"sha\", \"-p\", \"/Movies\", \"PikPak://a|1|sha\"}},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trequire.Equal(t, tt.want, adaptShellArgs(rootCmd, \"/Movies\", tt.args))\n\t\t})\n\t}\n}\n\nfunc TestExpandShellGlobsDownload(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tdownloadCmd := &cobra.Command{Use: \"download\"}\n\tdownloadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tdownloadCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\trootCmd.AddCommand(downloadCmd)\n\n\targs := adaptShellArgs(rootCmd, \"/Movies\", []string{\"download\", \"*.mp4\"})\n\texpanded, err := expandShellGlobs(rootCmd, \"/Movies\", fakeFileStatProvider{\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies-id\": {\n\t\t\t\t{Name: \"movie-1.mp4\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"movie-2.mp4\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"note.txt\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t\tids: map[string]string{\n\t\t\t\"/Movies\": \"movies-id\",\n\t\t},\n\t}, args)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"download\", \"-p\", \"/Movies\", \"movie-1.mp4\", \"movie-2.mp4\"}, expanded)\n}\n\nfunc TestExpandShellGlobsDelete(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tdeleteCmd := &cobra.Command{Use: \"delete\"}\n\tdeleteCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\trootCmd.AddCommand(deleteCmd)\n\n\targs := adaptShellArgs(rootCmd, \"/Movies\", []string{\"delete\", \"*.srt\"})\n\texpanded, err := expandShellGlobs(rootCmd, \"/Movies\", fakeFileStatProvider{\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies-id\": {\n\t\t\t\t{Name: \"episode-1.srt\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"episode-2.srt\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"episode-1.mkv\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t\tids: map[string]string{\n\t\t\t\"/Movies\": \"movies-id\",\n\t\t},\n\t}, args)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"delete\", \"/Movies/episode-1.srt\", \"/Movies/episode-2.srt\"}, expanded)\n}\n\nfunc TestExpandShellGlobsUpload(t *testing.T) {\n\trootCmd := &cobra.Command{Use: \"pikpakcli\"}\n\tuploadCmd := &cobra.Command{Use: \"upload\"}\n\tuploadCmd.Flags().StringP(\"path\", \"p\", \"/\", \"\")\n\tuploadCmd.Flags().StringP(\"parent-id\", \"P\", \"\", \"\")\n\tuploadCmd.Flags().Int64P(\"concurrency\", \"c\", 16, \"\")\n\tuploadCmd.Flags().StringSliceP(\"exn\", \"e\", nil, \"\")\n\tuploadCmd.Flags().BoolP(\"sync\", \"s\", false, \"\")\n\trootCmd.AddCommand(uploadCmd)\n\n\ttempDir := t.TempDir()\n\tvideoA := filepath.Join(tempDir, \"a.mkv\")\n\tvideoB := filepath.Join(tempDir, \"b.mkv\")\n\tnote := filepath.Join(tempDir, \"note.txt\")\n\trequire.NoError(t, os.WriteFile(videoA, []byte(\"a\"), 0o644))\n\trequire.NoError(t, os.WriteFile(videoB, []byte(\"b\"), 0o644))\n\trequire.NoError(t, os.WriteFile(note, []byte(\"c\"), 0o644))\n\n\targs := adaptShellArgs(rootCmd, \"/Movies\", []string{\"upload\", filepath.Join(tempDir, \"*.mkv\")})\n\texpanded, err := expandShellGlobs(rootCmd, \"/Movies\", fakeFileStatProvider{}, args)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"upload\", \"-p\", \"/Movies\", videoA, videoB}, expanded)\n}\n\nfunc TestExpandOpenGlobs(t *testing.T) {\n\texpanded, err := expandOpenGlobs(\"/Movies\", fakeFileStatProvider{\n\t\tfolders: map[string][]api.FileStat{\n\t\t\t\"movies-id\": {\n\t\t\t\t{Name: \"movie-1.mp4\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"movie-2.mp4\", Kind: api.FileKindFile},\n\t\t\t\t{Name: \"cover.jpg\", Kind: api.FileKindFile},\n\t\t\t},\n\t\t},\n\t\tids: map[string]string{\n\t\t\t\"/Movies\": \"movies-id\",\n\t\t},\n\t}, []string{\"*.mp4\"})\n\trequire.NoError(t, err)\n\trequire.Equal(t, []string{\"/Movies/movie-1.mp4\", \"/Movies/movie-2.mp4\"}, expanded)\n}\n\nfunc TestCompleterCDPath(t *testing.T) {\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd: &cobra.Command{Use: \"pikpakcli\"},\n\t\tfileStatSource: fakeFileStatProvider{\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"\": {\n\t\t\t\t\t{Name: \"Movies\", Kind: api.FileKindFolder},\n\t\t\t\t\t{Name: \"Music\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t\t\"movies-id\": {\n\t\t\t\t\t{Name: \"Kids Cartoons\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t},\n\t\t\tids: map[string]string{\n\t\t\t\t\"/Movies\": \"movies-id\",\n\t\t\t},\n\t\t},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"cd /Mov\"), len(\"cd /Mov\"))\n\trequire.Equal(t, len([]rune(\"/Mov\")), offset)\n\trequire.Contains(t, candidates, []rune(\"ies/\"))\n}\n\nfunc TestCompleterCDPathFromCurrentDirectory(t *testing.T) {\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd: &cobra.Command{Use: \"pikpakcli\"},\n\t\tfileStatSource: fakeFileStatProvider{\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"movies-id\": {\n\t\t\t\t\t{Name: \"Kids Cartoons\", Kind: api.FileKindFolder},\n\t\t\t\t\t{Name: \"Drama\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t},\n\t\t\tids: map[string]string{\n\t\t\t\t\"/Movies\": \"movies-id\",\n\t\t\t},\n\t\t},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/Movies\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"cd Ki\"), len(\"cd Ki\"))\n\trequire.Equal(t, len([]rune(\"Ki\")), offset)\n\trequire.Contains(t, candidates, []rune(`ds\\ Cartoons/`))\n}\n\nfunc TestCompleterEscapesSpacesInPath(t *testing.T) {\n\tcompleter := &shellAutoCompleter{\n\t\trootCmd: &cobra.Command{Use: \"pikpakcli\"},\n\t\tfileStatSource: fakeFileStatProvider{\n\t\t\tfolders: map[string][]api.FileStat{\n\t\t\t\t\"\": {\n\t\t\t\t\t{Name: \"My Pack\", Kind: api.FileKindFolder},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tcurrentPath: func() string {\n\t\t\treturn \"/\"\n\t\t},\n\t}\n\n\tcandidates, offset := completer.Do([]rune(\"cd /My\"), len(\"cd /My\"))\n\trequire.Equal(t, len([]rune(\"/My\")), offset)\n\trequire.Contains(t, candidates, []rune(`\\ Pack/`))\n}\n\ntype fakeFileStatProvider struct {\n\tfolders map[string][]api.FileStat\n\tids     map[string]string\n}\n\nfunc (f fakeFileStatProvider) GetPathFolderId(dirPath string) (string, error) {\n\tif id, ok := f.ids[dirPath]; ok {\n\t\treturn id, nil\n\t}\n\treturn \"\", nil\n}\n\nfunc (f fakeFileStatProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {\n\treturn f.folders[parentId], nil\n}\n\nfunc TestClassifyOpenCategory(t *testing.T) {\n\trequire.Equal(t, openCategoryText, classifyOpenCategory(\"readme.md\"))\n\trequire.Equal(t, openCategoryImage, classifyOpenCategory(\"cover.png\"))\n\trequire.Equal(t, openCategoryVideo, classifyOpenCategory(\"movie.mkv\"))\n\trequire.Equal(t, openCategoryAudio, classifyOpenCategory(\"song.flac\"))\n\trequire.Equal(t, openCategoryPDF, classifyOpenCategory(\"paper.pdf\"))\n\trequire.Equal(t, openCategoryDefault, classifyOpenCategory(\"archive.zip\"))\n}\n\nfunc TestBuildOpenCommand(t *testing.T) {\n\tname, args, err := buildOpenCommand(\"darwin\", conf.OpenConfig{}, \"/tmp/demo.txt\", openCategoryText)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"open\", name)\n\trequire.Equal(t, []string{\"-a\", \"TextEdit\", \"/tmp/demo.txt\"}, args)\n\n\tname, args, err = buildOpenCommand(\"darwin\", conf.OpenConfig{}, \"/tmp/demo.mp4\", openCategoryVideo)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"open\", name)\n\trequire.Equal(t, []string{\"-a\", \"IINA\", \"/tmp/demo.mp4\"}, args)\n\n\tname, args, err = buildOpenCommand(\"linux\", conf.OpenConfig{\n\t\tVideo: []string{\"vlc\", \"--fullscreen\"},\n\t}, \"/tmp/demo.mp4\", openCategoryVideo)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"vlc\", name)\n\trequire.Equal(t, []string{\"--fullscreen\", \"/tmp/demo.mp4\"}, args)\n\n\tname, args, err = buildOpenCommand(\"linux\", conf.OpenConfig{\n\t\tDefault: []string{\"custom-open\", \"--file\", \"{path}\"},\n\t}, \"/tmp/demo.bin\", openCategoryDefault)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"custom-open\", name)\n\trequire.Equal(t, []string{\"--file\", \"/tmp/demo.bin\"}, args)\n}\n\nfunc TestRemoteVideoOpenURL(t *testing.T) {\n\tfile := &api.File{}\n\tfile.Medias = []struct {\n\t\tMediaID   string      `json:\"media_id\"`\n\t\tMediaName string      `json:\"media_name\"`\n\t\tVideo     interface{} `json:\"video\"`\n\t\tLink      struct {\n\t\t\tURL    string    `json:\"url\"`\n\t\t\tToken  string    `json:\"token\"`\n\t\t\tExpire time.Time `json:\"expire\"`\n\t\t} `json:\"link\"`\n\t\tNeedMoreQuota  bool          `json:\"need_more_quota\"`\n\t\tVipTypes       []interface{} `json:\"vip_types\"`\n\t\tRedirectLink   string        `json:\"redirect_link\"`\n\t\tIconLink       string        `json:\"icon_link\"`\n\t\tIsDefault      bool          `json:\"is_default\"`\n\t\tPriority       int           `json:\"priority\"`\n\t\tIsOrigin       bool          `json:\"is_origin\"`\n\t\tResolutionName string        `json:\"resolution_name\"`\n\t\tIsVisible      bool          `json:\"is_visible\"`\n\t\tCategory       string        `json:\"category\"`\n\t}{\n\t\t{\n\t\t\tLink: struct {\n\t\t\t\tURL    string    `json:\"url\"`\n\t\t\t\tToken  string    `json:\"token\"`\n\t\t\t\tExpire time.Time `json:\"expire\"`\n\t\t\t}{URL: \"https://example.com/visible.m3u8\"},\n\t\t\tIsVisible: true,\n\t\t},\n\t\t{\n\t\t\tLink: struct {\n\t\t\t\tURL    string    `json:\"url\"`\n\t\t\t\tToken  string    `json:\"token\"`\n\t\t\t\tExpire time.Time `json:\"expire\"`\n\t\t\t}{URL: \"https://example.com/default.m3u8\"},\n\t\t\tIsDefault: true,\n\t\t\tIsVisible: true,\n\t\t},\n\t}\n\n\trequire.Equal(t, \"https://example.com/default.m3u8\", remoteVideoOpenURL(file))\n}\n\nfunc TestResolveOpenTargetForVideoPrefersRemoteURL(t *testing.T) {\n\tfile := &api.File{}\n\tfile.Name = \"movie.mkv\"\n\tfile.Links.ApplicationOctetStream.URL = \"https://example.com/download.mp4\"\n\n\ttarget, err := resolveOpenTarget(file)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"https://example.com/download.mp4\", target)\n}\n"
  },
  {
    "path": "internal/utils/format.go",
    "content": "package utils\n\nimport \"strconv\"\n\nvar storageUnits = [...]string{\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"}\n\nfunc FormatStorage(sizeText string, human bool) string {\n\tif !human {\n\t\treturn sizeText\n\t}\n\tsize, err := strconv.ParseFloat(sizeText, 64)\n\tif err != nil {\n\t\treturn sizeText\n\t}\n\n\tunit := 0\n\tfor size >= 1024 && unit < len(storageUnits)-1 {\n\t\tsize /= 1024\n\t\tunit++\n\t}\n\n\tif size == float64(int64(size)) {\n\t\treturn strconv.FormatFloat(size, 'f', 0, 64) + storageUnits[unit]\n\t}\n\n\treturn strconv.FormatFloat(size, 'f', 2, 64) + storageUnits[unit]\n}\n"
  },
  {
    "path": "internal/utils/format_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFormatStorage(t *testing.T) {\n\tassert.Equal(t, \"2048\", FormatStorage(\"2048\", false))\n\tassert.Equal(t, \"2KB\", FormatStorage(\"2048\", true))\n\tassert.Equal(t, \"1.50KB\", FormatStorage(\"1536\", true))\n\tassert.Equal(t, \"bad\", FormatStorage(\"bad\", true))\n\tassert.Equal(t, \"1KB\", FormatStorage(\"1024\", true))\n}\n"
  },
  {
    "path": "internal/utils/path.go",
    "content": "package utils\n\nimport (\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nfunc ExpandLocalPath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tif path == \"\" {\n\t\treturn path\n\t}\n\n\tpath = os.ExpandEnv(path)\n\tif path == \"~\" {\n\t\thome, err := os.UserHomeDir()\n\t\tif err == nil {\n\t\t\treturn home\n\t\t}\n\t\treturn path\n\t}\n\n\tprefix := \"~\" + string(filepath.Separator)\n\tif strings.HasPrefix(path, prefix) {\n\t\thome, err := os.UserHomeDir()\n\t\tif err == nil {\n\t\t\treturn filepath.Join(home, path[len(prefix):])\n\t\t}\n\t}\n\n\treturn path\n}\n\nfunc SplitSeparator(path string) []string {\n\tif path == \"\" {\n\t\treturn []string{}\n\t}\n\treturn strings.Split(path, string(filepath.Separator))\n}\n\nfunc Slash(path string) string {\n\t// clean path\n\tpath = filepath.Clean(path)\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tif path[0] == filepath.Separator {\n\t\treturn path[1:]\n\t}\n\treturn path\n}\n\nfunc SplitRemotePath(path string) (dir string, name string) {\n\tpath = filepath.Clean(path)\n\tif path == \".\" || path == string(filepath.Separator) {\n\t\treturn \"\", \"\"\n\t}\n\n\tname = filepath.Base(path)\n\tif name == \".\" || name == string(filepath.Separator) {\n\t\treturn \"\", \"\"\n\t}\n\n\tdir = filepath.Dir(path)\n\tif dir == \".\" {\n\t\tdir = \"\"\n\t}\n\tif dir == \"\" {\n\t\treturn \"\", name\n\t}\n\n\treturn Slash(dir), name\n}\n\n// 获取目录文件夹下的所有文件路径名\nfunc GetUploadFilePath(basePath string, defaultRegexp []*regexp.Regexp) ([]string, error) {\n\trawPath := make([]string, 0)\n\terr := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// match regexp\n\t\t// if matched, then skip\n\t\t// else append\n\t\tmatchRegexp := func(name string) bool {\n\t\t\tfor _, r := range defaultRegexp {\n\t\t\t\tif r.MatchString(name) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\n\t\tif matchRegexp(d.Name()) {\n\t\t\tif d.IsDir() {\n\t\t\t\treturn filepath.SkipDir\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\n\t\t// skip dir\n\t\tif d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\t// get relative path\n\t\trefPath, err := filepath.Rel(basePath, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// append to rawPath\n\t\trawPath = append(rawPath, refPath)\n\t\treturn nil\n\t})\n\treturn rawPath, err\n}\n\n// 检查路径是否存在\nfunc Exists(path string) (bool, error) {\n\t_, err := os.Stat(path)\n\tif err == nil {\n\t\treturn true, nil\n\t}\n\tif os.IsNotExist(err) {\n\t\treturn false, nil\n\t}\n\treturn false, err\n}\n\n// 不存在目录就创建\nfunc CreateDirIfNotExist(path string) error {\n\texist, err := Exists(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exist {\n\t\terr := os.MkdirAll(path, os.ModePerm)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// 创建空文件\nfunc TouchFile(path string) error {\n\texist, err := Exists(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exist {\n\t\tf, err := os.Create(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tf.Close()\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/utils/path_test.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestSplitRemotePath(t *testing.T) {\n\tseparator := string(filepath.Separator)\n\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\twantDir  string\n\t\twantName string\n\t}{\n\t\t{\n\t\t\tname:     \"full path\",\n\t\t\tinput:    separator + filepath.Join(\"Movies\", \"Peppa_Pig.mp4\"),\n\t\t\twantDir:  \"Movies\",\n\t\t\twantName: \"Peppa_Pig.mp4\",\n\t\t},\n\t\t{\n\t\t\tname:     \"relative nested path\",\n\t\t\tinput:    filepath.Join(\"Movies\", \"Kids\", \"Peppa_Pig.mp4\"),\n\t\t\twantDir:  filepath.Join(\"Movies\", \"Kids\"),\n\t\t\twantName: \"Peppa_Pig.mp4\",\n\t\t},\n\t\t{\n\t\t\tname:     \"file name only\",\n\t\t\tinput:    \"Peppa_Pig.mp4\",\n\t\t\twantDir:  \"\",\n\t\t\twantName: \"Peppa_Pig.mp4\",\n\t\t},\n\t\t{\n\t\t\tname:     \"root path\",\n\t\t\tinput:    separator,\n\t\t\twantDir:  \"\",\n\t\t\twantName: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdir, name := SplitRemotePath(tt.input)\n\t\t\trequire.Equal(t, tt.wantDir, dir)\n\t\t\trequire.Equal(t, tt.wantName, name)\n\t\t})\n\t}\n}\n\nfunc TestExpandLocalPath(t *testing.T) {\n\thome, err := os.UserHomeDir()\n\trequire.NoError(t, err)\n\n\trequire.Equal(t, home, ExpandLocalPath(\"~\"))\n\trequire.Equal(t, filepath.Join(home, \"Downloads\"), ExpandLocalPath(\"~/Downloads\"))\n\trequire.Equal(t, filepath.Join(home, \"Downloads\"), ExpandLocalPath(\"$HOME/Downloads\"))\n\trequire.Equal(t, \"relative/path\", ExpandLocalPath(\"relative/path\"))\n}\n"
  },
  {
    "path": "internal/utils/sync.go",
    "content": "package utils\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"unsafe\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n)\n\nvar ErrSyncTxtNotEnable = errors.New(\"sync txt is not enable\")\n\ntype SyncTxt struct {\n\tEnable        bool\n\tFileName      string\n\talreadySynced []string\n\tf             *os.File\n}\n\nfunc NewSyncTxt(fileName string, enable bool) (sync *SyncTxt, err error) {\n\tvar f *os.File = nil\n\tvar alreadySynced []string\n\tif enable {\n\t\tf, err = os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tbs, err := io.ReadAll(f)\n\t\tif err != nil {\n\t\t\tlogx.Warn(\"sync\", \"read file error: \", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\t// avoid end with \"\\n\"\n\t\talreadySynced = strings.Split(\n\t\t\tstrings.TrimRight(unsafe.String(unsafe.SliceData(bs), len(bs)), \"\\n\"),\n\t\t\t\"\\n\",\n\t\t)\n\t}\n\treturn &SyncTxt{\n\t\tEnable:        enable,\n\t\tFileName:      fileName,\n\t\tf:             f,\n\t\talreadySynced: alreadySynced,\n\t}, nil\n}\n\n// impl Writer\nfunc (s *SyncTxt) Write(b []byte) (n int, err error) {\n\tif !s.Enable {\n\t\treturn 0, ErrSyncTxtNotEnable\n\t}\n\tif b[len(b)-1] != '\\n' {\n\t\tb = append(b, '\\n')\n\t}\n\t// add to alreadySynced\n\ts.alreadySynced = append(s.alreadySynced, strings.TrimRight(string(b), \"\\n\"))\n\treturn s.f.Write(b)\n}\n\n// impl Closer\nfunc (s *SyncTxt) Close() error {\n\tif !s.Enable {\n\t\treturn ErrSyncTxtNotEnable\n\t}\n\treturn s.f.Close()\n}\n\n// impl StringWriter\nfunc (s *SyncTxt) WriteString(str string) (n int, err error) {\n\tif !s.Enable {\n\t\treturn 0, ErrSyncTxtNotEnable\n\t}\n\tif str[len(str)-1] != '\\n' {\n\t\tstr += \"\\n\"\n\t}\n\t// add to alreadySynced\n\ts.alreadySynced = append(s.alreadySynced, strings.TrimRight(str, \"\\n\"))\n\treturn s.f.WriteString(str)\n}\n\nfunc (s *SyncTxt) UnSync(files []string) []string {\n\tif !s.Enable {\n\t\treturn files\n\t}\n\tres := make([]string, 0)\n\tfor _, f := range files {\n\t\tif slices.Contains(s.alreadySynced, f) {\n\t\t\tcontinue\n\t\t}\n\t\tres = append(res, f)\n\t}\n\treturn res\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport \"github.com/52funny/pikpakcli/cli\"\n\nfunc main() {\n\tcli.Execute()\n}\n"
  },
  {
    "path": "rules/README.md",
    "content": "# Rubbish Rules\n\nThis directory stores text rule files used by the `rubbish` command.\n\n## Files\n\n- `rubbish_rules.txt`: default rubbish matching rules\n\n## Rule Format\n\n- One rule per line\n- Empty lines are ignored\n- Lines starting with `#` are comments\n- Lines starting with `!` are exclude rules\n\n## Examples\n\n```txt\n.DS_Store\n*.tmp\ncache/*.part\n!important.tmp\n!/System/*\n```\n\n## Usage\n\n```bash\npikpakcli rubbish --rules rules/rubbish_rules.txt\npikpakcli rubbish --rules rules/rubbish_rules.txt --delete\n```\n\n## Contributions\n\nIf you find common rubbish files or directories that should be covered by the default rules, PRs are welcome.\n"
  },
  {
    "path": "rules/rubbish_rules.txt",
    "content": "# Rubbish match rules for the future `rubbish` command.\n# Format:\n# - one rule per line\n# - empty lines are ignored\n# - lines starting with # are comments\n# - lines starting with ! are exclude rules\n#\n# Suggested matching behavior:\n# - exact names:      .DS_Store\n# - wildcard names:   *.tmp\n# - path patterns:    cache/*.part\n# - exclude rules:    !important/*.tmp\n#\n# Example:\n# pikpakcli rubbish --rules rules/rubbish_rules.txt --delete\n\n# macOS metadata\n.DS_Store\n._*\n\n# Windows metadata\nThumbs.db\ndesktop.ini\n\n# partial downloads\n*.part\n*.crdownload\n*.download\n*.tmp\n\n# editor temp files\n*.swp\n*.swo\n*~\n*.bak\n\n# optional examples\n# *.ass.tmp\n# *.srt.tmp\n\n# exclude rules\n!.gitkeep\n!.keep\n!/System/*\n!/Applications/*\n"
  }
]