Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 119 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ A read-only command-line interface for Google services. Search, read, and view G

## Features

- **Read-only access** - Uses `gmail.readonly`, `calendar.readonly`, and `contacts.readonly` OAuth scopes
- **Read-only access** - Uses `gmail.readonly`, `calendar.readonly`, `contacts.readonly`, and `drive.readonly` OAuth scopes
- **Gmail support** - Search messages, read content, view threads, list labels, download attachments
- **Calendar support** - List calendars, view events, today/week shortcuts
- **Contacts support** - List contacts, search, view details, list groups
- **Drive support** - List files, search, view metadata, download files, folder tree
- **JSON output** - Machine-readable output for scripting
- **Secure storage** - OAuth tokens stored in system keychain (macOS/Linux)

Expand Down Expand Up @@ -101,6 +102,7 @@ go install github.com/open-cli-collective/google-readonly/cmd/gro@latest
- Search for and enable: **Gmail API**
- Enable: **Google Calendar API**
- Enable: **People API** (for Contacts)
- Enable: **Google Drive API**

### 2. Create OAuth Credentials

Expand All @@ -113,6 +115,7 @@ go install github.com/open-cli-collective/google-readonly/cmd/gro@latest
- `https://www.googleapis.com/auth/gmail.readonly`
- `https://www.googleapis.com/auth/calendar.readonly`
- `https://www.googleapis.com/auth/contacts.readonly`
- `https://www.googleapis.com/auth/drive.readonly`
- Add your email as a test user
4. For Application type, select **Desktop app**
5. Click **Create**
Expand Down Expand Up @@ -485,6 +488,121 @@ Flags:
-j, --json Output as JSON
```

### Drive Commands

All Drive commands are under `gro drive` (or `gro files`):

```bash
# List files in root or folder
gro drive list
gro files list --max 20
gro drive list <folder-id> --type document

# Search files
gro drive search "quarterly report"
gro files search --name "budget" --type spreadsheet
gro drive search --modified-after 2024-01-01

# Get file metadata
gro drive get <file-id>
gro files get <file-id> --json

# Download files
gro drive download <file-id>
gro files download <file-id> --output ./report.pdf
gro drive download <file-id> --format pdf # Export Google Doc as PDF
gro drive download <file-id> --stdout # Write to stdout

# Show folder tree
gro drive tree
gro files tree <folder-id> --depth 3
gro drive tree --files # Include files, not just folders
```

### gro drive list

List files in Google Drive root or a specific folder.

```
Usage: gro drive list [folder-id] [flags]

Aliases: gro files list

Flags:
-m, --max int Maximum number of files (default 25)
-t, --type string Filter by type (document, spreadsheet, presentation, folder, pdf, image, video, audio)
-j, --json Output as JSON
```

### gro drive search

Search for files in Google Drive.

```
Usage: gro drive search [query] [flags]

Aliases: gro files search

Flags:
-n, --name string Search by filename only
-t, --type string Filter by file type
--owner string Filter by owner (me, or email)
--modified-after string Modified after date (YYYY-MM-DD)
--modified-before string Modified before date (YYYY-MM-DD)
--in-folder string Search within folder ID
-m, --max int Maximum results (default 25)
-j, --json Output as JSON
```

### gro drive get

Get detailed metadata for a file.

```
Usage: gro drive get <file-id> [flags]

Aliases: gro files get

Flags:
-j, --json Output as JSON
```

### gro drive download

Download a file or export a Google Workspace file.

```
Usage: gro drive download <file-id> [flags]

Aliases: gro files download

Flags:
-o, --output string Output file path
-f, --format string Export format for Google Workspace files
--stdout Write to stdout instead of file
```

Export formats for Google Workspace files:
- **Documents**: pdf, docx, txt, html, md, rtf, odt
- **Spreadsheets**: pdf, xlsx, csv, tsv, ods
- **Presentations**: pdf, pptx, odp
- **Drawings**: pdf, png, svg, jpg

### gro drive tree

Display folder structure as a tree.

```
Usage: gro drive tree [folder-id] [flags]

Aliases: gro files tree

Flags:
-d, --depth int Maximum depth to traverse (default 2)
--files Include files in addition to folders
-j, --json Output as JSON
```

## Search Query Reference

gro supports all Gmail search operators:
Expand Down Expand Up @@ -601,10 +719,6 @@ Your OAuth consent screen may not be properly configured. Ensure:
2. Your email is added as a test user (for apps in testing mode)
3. The required scopes are added

## Future Features

- **Google Drive** - List files, download content

## License

MIT License - see [LICENSE](LICENSE) for details.
151 changes: 151 additions & 0 deletions internal/cmd/drive/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package drive

import (
"bytes"
"fmt"
"io"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"

"github.com/open-cli-collective/google-readonly/internal/drive"
)

func TestTreeCommand(t *testing.T) {
Expand Down Expand Up @@ -211,3 +215,150 @@ func TestTreeNode(t *testing.T) {
assert.Nil(t, node.Children)
})
}

// mockDriveClient implements drive.DriveClientInterface for testing
type mockDriveClient struct {
files map[string]*drive.File // fileID -> File
children map[string][]*drive.File // folderID -> children
}

func newMockDriveClient() *mockDriveClient {
return &mockDriveClient{
files: make(map[string]*drive.File),
children: make(map[string][]*drive.File),
}
}

func (m *mockDriveClient) GetFile(fileID string) (*drive.File, error) {
if f, ok := m.files[fileID]; ok {
return f, nil
}
return nil, fmt.Errorf("file not found: %s", fileID)
}

func (m *mockDriveClient) ListFiles(query string, _ int64) ([]*drive.File, error) {
// Extract folderID from query like "'folder123' in parents and trashed = false"
// The query format is: "'<folderID>' in parents and trashed = false"
for folderID, files := range m.children {
searchPattern := fmt.Sprintf("'%s' in parents", folderID)
if strings.Contains(query, searchPattern) {
return files, nil
}
}
return []*drive.File{}, nil
}

func (m *mockDriveClient) DownloadFile(_ string) ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}

func (m *mockDriveClient) ExportFile(_ string, _ string) ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}

func TestBuildTree(t *testing.T) {
t.Run("builds tree for root folder", func(t *testing.T) {
mock := newMockDriveClient()
// Set up child folders in both children and files maps
mock.children["root"] = []*drive.File{
{ID: "folder1", Name: "Documents", MimeType: drive.MimeTypeFolder},
{ID: "folder2", Name: "Photos", MimeType: drive.MimeTypeFolder},
}
// GetFile is called for each child folder during recursion
mock.files["folder1"] = &drive.File{ID: "folder1", Name: "Documents", MimeType: drive.MimeTypeFolder}
mock.files["folder2"] = &drive.File{ID: "folder2", Name: "Photos", MimeType: drive.MimeTypeFolder}

tree, err := buildTree(mock, "root", 1, false)

assert.NoError(t, err)
assert.Equal(t, "root", tree.ID)
assert.Equal(t, "My Drive", tree.Name)
assert.Equal(t, "Folder", tree.Type)
assert.Len(t, tree.Children, 2)
})

t.Run("builds tree for specific folder", func(t *testing.T) {
mock := newMockDriveClient()
mock.files["folder123"] = &drive.File{
ID: "folder123",
Name: "My Folder",
MimeType: drive.MimeTypeFolder,
}
mock.children["folder123"] = []*drive.File{
{ID: "doc1", Name: "Notes.txt", MimeType: "text/plain"},
}

tree, err := buildTree(mock, "folder123", 1, true)

assert.NoError(t, err)
assert.Equal(t, "folder123", tree.ID)
assert.Equal(t, "My Folder", tree.Name)
assert.Len(t, tree.Children, 1)
assert.Equal(t, "Notes.txt", tree.Children[0].Name)
})

t.Run("respects depth limit", func(t *testing.T) {
mock := newMockDriveClient()
mock.children["root"] = []*drive.File{
{ID: "folder1", Name: "Level1", MimeType: drive.MimeTypeFolder},
}
mock.files["folder1"] = &drive.File{ID: "folder1", Name: "Level1", MimeType: drive.MimeTypeFolder}
mock.children["folder1"] = []*drive.File{
{ID: "folder2", Name: "Level2", MimeType: drive.MimeTypeFolder},
}
mock.files["folder2"] = &drive.File{ID: "folder2", Name: "Level2", MimeType: drive.MimeTypeFolder}

// With depth 1, should not recurse into Level1
tree, err := buildTree(mock, "root", 1, false)

assert.NoError(t, err)
assert.Len(t, tree.Children, 1)
assert.Equal(t, "Level1", tree.Children[0].Name)
// Children of Level1 should be empty due to depth limit
assert.Empty(t, tree.Children[0].Children)
})

t.Run("returns node with no children at depth 0", func(t *testing.T) {
mock := newMockDriveClient()
mock.children["root"] = []*drive.File{
{ID: "folder1", Name: "Folder", MimeType: drive.MimeTypeFolder},
}

tree, err := buildTree(mock, "root", 0, false)

assert.NoError(t, err)
assert.Equal(t, "My Drive", tree.Name)
assert.Nil(t, tree.Children)
})

t.Run("includes files when includeFiles is true", func(t *testing.T) {
mock := newMockDriveClient()
mock.children["root"] = []*drive.File{
{ID: "folder1", Name: "Docs", MimeType: drive.MimeTypeFolder},
{ID: "file1", Name: "readme.txt", MimeType: "text/plain"},
}
mock.files["folder1"] = &drive.File{ID: "folder1", Name: "Docs", MimeType: drive.MimeTypeFolder}

tree, err := buildTree(mock, "root", 1, true)

assert.NoError(t, err)
assert.Len(t, tree.Children, 2)
})

t.Run("sorts folders before files", func(t *testing.T) {
mock := newMockDriveClient()
mock.children["root"] = []*drive.File{
{ID: "file1", Name: "aaa.txt", MimeType: "text/plain"},
{ID: "folder1", Name: "zzz-folder", MimeType: drive.MimeTypeFolder},
}
mock.files["folder1"] = &drive.File{ID: "folder1", Name: "zzz-folder", MimeType: drive.MimeTypeFolder}

tree, err := buildTree(mock, "root", 1, true)

assert.NoError(t, err)
assert.Len(t, tree.Children, 2)
// Folder should come first despite alphabetical order
assert.Equal(t, "zzz-folder", tree.Children[0].Name)
assert.Equal(t, "aaa.txt", tree.Children[1].Name)
})
}
Loading