gdoctableapp

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 22, 2020 License: MIT Imports: 10 Imported by: 0

README

go-gdoctableapp

Build Status MIT License

Overview

This is a Golang library for managing tables on Google Document using Google Docs API.

Description

Google Docs API has been released. When I used this API, I found that it is very difficult for me to manage the tables on Google Document using Google Docs API. Although I checked the official document, unfortunately, I thought that it's very difficult for me. So in order to easily manage the tables on Google Document, I created this library.

Features

  • All values can be retrieved from the table on Google Document.

  • Values can be put to the table.

  • Delete table, rows and columns of the table.

  • New table can be created by including values.

  • Append rows to the table by including values.

  • Replace texts with images.

    	- The image data can be retrieved from URL.
    	- The image data can be uploaded from the local PC.
    

Languages

I manages the tables on Google Document using several languages. So I created the libraries for 4 languages which are golang, node.js and python. Google Apps Script has Class DocumentApp. So I has never created the GAS library yet.

Install

You can install this using go get as follows.

$ go get -v -u github.com/tanaikech/go-gdoctableapp

This library uses google-api-go-client.

Method

Method Explanation
GetTables() Get all tables from Document.
GetValues() Get values from a table from Document.
SetValuesBy2DArray(values [][]interface{}) Set values to a table with 2 dimensional array.
SetValuesByObject(values []ValueObject) Set values to a table with an object.
DeleteTable() Delete a table.
DeleteRowsAndColumns(d *DeleteRowsColumnsRequest) Delete rows and columns of a table.
CreateTable(c *CreateTableRequest) Create new table including sell values.
AppendRow(c *AppendRowRequest) Append row to a table by including values.
ReplaceTextsToImagesByURL(from, to string) Replace texts with images from URL.
ReplaceTextsToImagesByFile(from, to string) Replace texts with images from files on local PC.

This library uses google-api-go-client.

Responses

The structure of response from this library is as follows.

Result struct {
	Tables           []Table       `json:"tables,omitempty"`
	Values           [][]string    `json:"values,omitempty"`
	ResponseFromAPIs []interface{} `json:"responseFromAPIs,omitempty"`
	LibraryVersion   string        `json:"libraryVersion"`
}
  • When GetTables() is used, you can see the values with Tables.
  • When GetValues() is used, you can see the values with Values.
  • When other methods are used and the option of ShowAPIResponse is true, you can see the responses from APIs which were used for the method. And also, you can know the number of APIs, which were used for the method, by the length of array of ResponseFromAPIs.

Usage

About the authorization, please check the section of Authorization. In order to use this library, it is required to confirm that the Quickstart works fine.

Scope

In this library, using the scope of https://www.googleapis.com/auth/documents is recommended. When the method of ReplaceTextsToImagesByFile is used, also please add https://www.googleapis.com/auth/drive.

1. GetTables

Get all tables from Document. All values, table index and table position are retrieved.

Sample script

This sample script retrieves all tables from the Google Document of document ID.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

res, err := g.Docs(documentID).GetTables().Do(client)

fmt.Println(res.Tables) // You can see the retrieved values like this.

the structure of res.Tables is as follows.

Table struct {
	Index         int64      `json:"index"` // TableIdx
	Values        [][]string `json:"values"`
	TablePosition struct {
		StartIndex int64 `json:"startIndex"`
		EndIndex   int64 `json:"endIndex"`
	}
}

When the option of ShowAPIResponse is used, the responses from Docs API can be retrieved. This option can be used for all methods.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

res, err := g.Docs(documentID).GetTables().ShowAPIResponse(true).Do(client)

fmt.Println(res.Tables) // You can see the retrieved values like this.
fmt.Println(res.ResponseFromAPIs) // You can see the responses from Docs API like this.

2. GetValues

Get values from the table. All values are retrieved.

Sample script

This sample script retrieves the values from 1st table in Google Document. You can see the retrieved values as [][]string. Because when the values are retrieved by Docs API, all values are automatically converted to the string data.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

res, err := g.Docs(documentID).TableIndex(tableIndex).GetValues().Do(client)

fmt.Println(res.Values) // You can see the retrieved values like this.
  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.

3. SetValuesBy2DArray

Set values to the table with 2 dimensional array. When the rows and columns of values which are put are over those of the table, this method can automatically expand the rows and columns.

Sample script

This sample script puts the values to the first table in Google Document.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

valuesBy2DArray := [][]interface{}{[]interface{}{"a1", "b1"}, []interface{}{"a2", "b2"}, []interface{}{"a3", "b3", "c3"}}
res, err := g.Docs(documentID).TableIndex(tableIndex).SetValuesBy2DArray(valuesBy2DArray).Do(client)

  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • valuesBy2DArray: [][]interface{}
Result

When above script is run, the following result is obtained.

From:

To:

4. SetValuesByObject

Set values to a table with an object. In this method, you can set the values using the range. When the rows and columns of values which are put are over those of the table, this method can automatically expand the rows and columns.

Sample script

This script puts the values with the range to the first table in Google Document.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

valuesByObject := []gdoctableapp.ValueObject{}

vo1 := &gdoctableapp.ValueObject{}
vo1.Range.StartRowIndex = 0
vo1.Range.StartColumnIndex = 0
vo1.Values = [][]interface{}{[]interface{}{"A1"}, []interface{}{"A2", "B2", "c2", "d2"}, []interface{}{"A3"}}
valuesByObject = append(valuesByObject, *vo1)

vo2 := &gdoctableapp.ValueObject{}
vo2.Range.StartRowIndex = 0
vo2.Range.StartColumnIndex = 1
vo2.Values = [][]interface{}{[]interface{}{"B1", "C1"}}
valuesByObject = append(valuesByObject, *vo2)

res, err := g.Docs(documentID).TableIndex(tableIndex).SetValuesByObject(valuesByObject).Do(client)
  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • Range.StartRowIndex of valuesByObject: Row index of values[0][0].
  • Range.StartColumnIndex of valuesByObject: Column index of values[0][0].
  • Values of valuesByObject: Values you want to put.

For example, when the row, column indexes and values are 1, 2 and "value", respectively, "value" is put to "C3".

Result

When above script is run, the following result is obtained.

From:

To:

5. DeleteTable

Sample script

This script deletes the first table in Google Document.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

res, err := g.Docs(documentID).TableIndex(tableIndex).DeleteTable().Do(client)
  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.

6. DeleteRowsAndColumns

Sample script

This script deletes rows of indexes of 3, 1 and 2 of the first table in Google Document. And also this script deletes columns of indexes of 2, 1 and 3.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

obj := &gdoctableapp.DeleteRowsColumnsRequest{
	Rows:    []int64{3, 1, 2}, // Start index is 0.
	Columns: []int64{2, 1, 3}, // Start index is 0.
}
res, err := g.Docs(documentID).TableIndex(tableIndex).DeleteRowsAndColumns(obj).Do(client)

  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • Rows of obj: Indexes of rows you want to delete.
  • Columns of obj: Indexes of columns you want to delete.

7. CreateTable

Sample script

This script creates new table to the top of Google Document, and the cells of the table have values.

documentID := "###"
g := gdoctableapp.New()

obj := &gdoctableapp.CreateTableRequest{
	Rows:    3,
	Columns: 5,
	Index:   1,
	// Append:  true, // When this is used instead of "Index", new table is created to the end of Document.
	Values: [][]interface{}{[]interface{}{"a1", "b1"}, []interface{}{"a2", "b2"}, []interface{}{"a3", "b3", "c3"}},
}
res, err := g.Docs(documentID).CreateTable(obj).Do(client)
  • documentID: Document ID.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • Rows of obj: Number of rows of new table.
  • Columns of obj: Number of columns of new table.
  • Index of obj: Index of Document for putting new table. For example, 1 is the top of Document.
  • Append of obj: When Append is true instead of Index, the new table is created to the end of Google Document.
  • Values of obj: If you want to put the values when new table is created, please use this.
Result

When above script is run, the following result is obtained. In this case, the new table is created to the top of Google Document.

8. AppendRow

Sample script

This sample script appends the values to the first table of Google Document.

documentID := "###"
tableIndex := 0
g := gdoctableapp.New()

obj := &gdoctableapp.AppendRowRequest{
	Values: [][]interface{}{[]interface{}{"a1", "b1", "c1", 1, "", 2}, []interface{}{"a2", "b2", "c2", 1, "", 2}},
}
res, err := g.Docs(documentID).TableIndex(tableIndex).AppendRow(obj).Do(client)
  • documentID: Document ID.
  • tableIndex: Table index. If you want to use the 3rd table in Google Document. It's 2. The start number of index is 0.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • Values of obj: Values you want to append to the existing table.
Result

When above script is run, the following result is obtained. In this case, the values are put to the last row. And you can see that 3 columns are automatically added when the script is run.

From:

To:

9. ReplaceTextsToImagesByURL and ReplaceTextsToImagesByFile

Sample script 1

In this sample, the texts {{sample}} in all tables are replaced with the image retrieved by the URL of https://###/sample.png.

documentID := "###"
searchText := "{{sample}}"
tableOnly := true
replaceImageURL := "https://###/sample.png"
g := gdoctableapp.New()

res, err := g.Docs(documentID).ReplaceTextsToImagesByURL(searchText, replaceImageURL).TableOnly(tableOnly).Do(client)
Sample script 2

In this sample, the texts {{sample}} in all tables are replaced with the image retrieved by the file of ./sample.png on your local PC.

documentID := "###"
searchText := "{{sample}}"
tableOnly := true // default is false
replaceImageFilePath := "./sample.png"
g := gdoctableapp.New()

res, err := g.Docs(documentID).ReplaceTextsToImagesByFile(searchText, replaceImageFilePath).TableOnly(tableOnly).Do(client)
  • documentID: Document ID.
  • client: *Client for using Docs API. Please check the section of Authorization.
  • searchText: Search text. This text is replaced with image.
  • tableOnly: When this is true, only texts in the table are replaced with image. When this is false, the texts in the body are replaced.
  • replaceImageURL: URL of the image.
  • replaceImageFilePath: File path of the image.

If you want to change the width and height of the image, please use the method of SetImageSize(width, height float64) like below.

res, err := g.Docs(documentID).SetImageSize(100, 100).ReplaceTextsToImagesByFile(searchText, replaceImageFilePath).TableOnly(tableOnly).Do(client)
Note
  • The flow for replacing the text with the image on the local PC.

    1. Upload the image from local PC to Google Drive.
    2. Publicly share the image file. - The time for sharing is several seconds. The file is delete after the image is put.
    3. Put the image using the URL of the publicly shared file.
    4. Delete the image. - Even when the image is delete from Google Drive, the put image on Google Document is not deleted.
  • About SetImageSize

    objectSize: The size that the image should appear as in the document. This property is optional and the final size of the image in the document is determined by the following rules: _ If neither width nor height is specified, then a default size of the image is calculated based on its resolution. _ If one dimension is specified then the other dimension is calculated to preserve the aspect ratio of the image. * If both width and height are specified, the image is scaled to fit within the provided dimensions while maintaining its aspect ratio.

Result

When above script is run, the following result is obtained.

From:

To:

The image of https://cdn.sstatic.net/Sites/stackoverflow/company/img/logos/so/so-logo.png was used as the sample image.

When tableOnly is false, the following result is retrieved.

Authorization

There are 2 patterns for using this library.

1. Use OAuth2

Document of OAuth2 is here.

Sample script

In this sample script, the authorization process uses the Quickstart for Go. You can see the detail information at there.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"

	gdoctableapp "github.com/tanaikech/go-gdoctableapp"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	docs "google.golang.org/api/docs/v1"
)

func getClient(ctx context.Context, config *oauth2.Config) *http.Client {
	cacheFile := "token.json"
	tok, err := tokenFromFile(cacheFile)
	if err != nil {
		tok = getTokenFromWeb(config)
		saveToken(cacheFile, tok)
	}
	return config.Client(ctx, tok)
}

func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
	authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
	fmt.Printf("Go to the following link in your browser then type the "+
		"authorization code: \n%v\n", authURL)

	var code string
	if _, err := fmt.Scan(&code); err != nil {
		log.Fatalf("Unable to read authorization code %v", err)
	}

	tok, err := config.Exchange(oauth2.NoContext, code)
	if err != nil {
		log.Fatalf("Unable to retrieve token from web %v", err)
	}
	return tok
}

func tokenFromFile(file string) (*oauth2.Token, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	t := &oauth2.Token{}
	err = json.NewDecoder(f).Decode(t)
	defer f.Close()
	return t, err
}

func saveToken(file string, token *oauth2.Token) {
	fmt.Printf("Saving credential file to: %s\n", file)
	f, err := os.Create(file)
	if err != nil {
		log.Fatalf("Unable to cache oauth token: %v", err)
	}
	defer f.Close()
	json.NewEncoder(f).Encode(token)
}

// OAuth2 : Use OAuth2
func OAuth2() *http.Client {
	b, err := ioutil.ReadFile("credentials.json")
	if err != nil {
		log.Fatalf("Unable to read client secret file: %v", err)
	}
	config, err := google.ConfigFromJSON(b, docs.DocumentsScope)
	if err != nil {
		log.Fatalf("Unable to parse client secret file to config: %v", err)
	}
	client := getClient(context.Background(), config)
	return client
}

func main() {
	documentID := "###" // Please set here
	tableIndex := 0     // Please set here

	client := OAuth2()
	g := gdoctableapp.New()

	res, err := g.Docs(documentID).TableIndex(tableIndex).GetValues().Do(client)

	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(res.GetValues)
}

2. Use Service account

Document of Service account is here. When you use Service account, please share Google Document with the email of Service account.

Sample script
package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"

	gdoctableapp "github.com/tanaikech/go-gdoctableapp"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"golang.org/x/oauth2/jwt"
	docs "google.golang.org/api/docs/v1"
)

// ServiceAccount : Use Service account
func ServiceAccount(credentialFile string) *http.Client {
	b, err := ioutil.ReadFile(credentialFile)
	if err != nil {
		log.Fatal(err)
	}
	var c = struct {
		Email      string `json:"client_email"`
		PrivateKey string `json:"private_key"`
	}{}
	json.Unmarshal(b, &c)
	config := &jwt.Config{
		Email:      c.Email,
		PrivateKey: []byte(c.PrivateKey),
		Scopes: []string{
			docs.DocumentsScope,
		},
		TokenURL: google.JWTTokenURL,
	}
	client := config.Client(oauth2.NoContext)
	return client
}

func main() {
	documentID := "###" // Please set here
	tableIndex := 0     // Please set here

	client := ServiceAccount("credential.json") // Please set here
	g := gdoctableapp.New()

	res, err := g.Docs(documentID).TableIndex(tableIndex).GetValues().Do(client)

	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(res.GetValues)
}

Sample scripts

Limitations

  • In the current stage, unfortunately, tableCellStyle cannot be modified by Google Docs API. By this, the formats of cells cannot be modified. About this, I have posted as Feature Request.

References:


Licence

MIT

Author

Tanaike

If you have any questions and commissions for me, feel free to tell me.

Update History

  • v1.0.0 (July 18, 2019)

    1. Initial release.
  • v1.0.5 (January 21, 2020)

    1. When the inline objects and tables are put in the table. An error occurred. This bug was removed by this update.
  • v1.1.0 (January 22, 2020)

    1. 2 new methods were added. From this version, the texts can be replaced by images. The direct link and local file can be used as the image.

TOP

Documentation

Overview

Package gdoctableapp (doc.go) : This is a Golang library for managing tables in Google Document using Google Docs API.

# Install You can get this by $ go get -u github.com/tanaikech/go-gdoctableapp

More information is https://github.com/tanaikech/go-gdoctableapp

---------------------------------------------------------------

Package gdoctableapp (methods.go) : This is a Golang library for managing tables in Google Document using Google Docs API. This file includes handler method.

Package gdoctableapp (methods.go) : This is a Golang library for managing tables in Google Document using Google Docs API. This file includes all public methods.

Package gdoctableapp (go-gdoctableapp.go) : This is a Golang library for managing tables in Google Document using Google Docs API.

Package gdoctableapp (methods.go) : This is a Golang library for managing tables in Google Document using Google Docs API. This file includes struct.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type AppendRowRequest

type AppendRowRequest struct {
	Values [][]interface{} `json:"values"`
}

AppendRowRequest : Object for appending row and values to existing table.

type CreateTableRequest

type CreateTableRequest struct {
	Rows    int64           `json:"rows"`
	Columns int64           `json:"columns"`
	Append  bool            `json:"append"`
	Index   int64           `json:"index"`
	Values  [][]interface{} `json:"values"`
}

CreateTableRequest : Object for creating new table with values.

type DeleteRowsColumnsRequest

type DeleteRowsColumnsRequest struct {
	Rows    []int64 `json:"deleteRows"`
	Columns []int64 `json:"deleteColumns"`
}

DeleteRowsColumnsRequest : Object for deleting rows and columns of a table.

type Params

type Params struct {
	AppendRowRequest         *AppendRowRequest
	Client                   *http.Client `json:"client"`
	CreateTableRequest       *CreateTableRequest
	DeleteRowsColumnsRequest *DeleteRowsColumnsRequest
	DocumentID               string          `json:"documentID"`
	ShowAPIResponseFlag      bool            `json:"showAPIResponseFlag"`
	TableIdx                 int             `json:"tableIdx"`
	ValuesArray              [][]interface{} `json:"valuesArray"`
	ValuesObject             []ValueObject   `json:"valuesObject"`
	ReplaceTextsToImagesP    struct {
		FileID           string  `json:"fileID"`
		ReplaceFromText  string  `json:"replaceFromText"`
		ReplaceToImage   string  `json:"replaceToImage"`
		ReplaceTableOnly bool    `json:"replaceTableOnly"`
		Width            float64 `json:"width"`
		Height           float64 `json:"height"`
	}
	Works struct {
		DoAppendRow                  bool `json:"doAppendRow"`
		DoCreateTable                bool `json:"doCreateTable"`
		DoDeleteTable                bool `json:"doDeleteTable"`
		DoDeleteRowsColumns          bool `json:"doDeleteRowsColumns"`
		DoGetValues                  bool `json:"doGetValues"`
		DoGetTables                  bool `json:"doGetTables"`
		DoValuesArray                bool `json:"doValuesArray"`
		DoValuesObject               bool `json:"doValuesObject"`
		DoReplaceTextsToImagesByURL  bool `json:"doReplaceTextsToImagesByURL"`
		DoReplaceTextsToImagesByFile bool `json:"doReplaceTextsToImagesByFile"`
	}
}

Params : Parameters inputted by users.

func New

func New() *Params

New : Create an object for using gdoctableapp

func (*Params) AppendRow

func (p *Params) AppendRow(c *AppendRowRequest) *Params

AppendRow : Append rows and values to existing table.

func (*Params) CreateTable

func (p *Params) CreateTable(c *CreateTableRequest) *Params

CreateTable : Create new table with values.

func (*Params) DeleteRowsAndColumns

func (p *Params) DeleteRowsAndColumns(d *DeleteRowsColumnsRequest) *Params

DeleteRowsAndColumns : Delete rows and columns of a table.

func (*Params) DeleteTable

func (p *Params) DeleteTable() *Params

DeleteTable : Delete table.

func (*Params) Do

func (p *Params) Do(client *http.Client) (*Result, error)

Do : Retrieve all file list and folder tree under root.

func (*Params) Docs

func (p *Params) Docs(documentID string) *Params

Docs : Set Document ID

func (*Params) GetTables

func (p *Params) GetTables() *Params

GetTables : Retrieve all tables from Google Document.

func (*Params) GetValues

func (p *Params) GetValues() *Params

GetValues : Retrieve values from a table of Google Document.

func (*Params) ReplaceTextsToImagesByFile

func (p *Params) ReplaceTextsToImagesByFile(from, to string) *Params

ReplaceTextsToImagesByFile : Replace texts to images in tables by an image file.

func (*Params) ReplaceTextsToImagesByURL

func (p *Params) ReplaceTextsToImagesByURL(from, to string) *Params

ReplaceTextsToImagesByURL : Replace texts to images in tables by an image URL.

from: Search text

to: URL of image for replacing the searched texts

tableOnly: When you want to replace the texts in only table cells, please set true. When you set false, the text is searched from all body and replaced to images.

sample:

var g *gdoctableapp.Params
searchText := "sample"
replaceImageURL := "https://sample/sample.png"
tableOnly := true
res, err := g.Docs(documentID).ReplaceTextsToImages(searchText, replaceImageURL, tableOnly).Do(client)

func (*Params) SetImageSize

func (p *Params) SetImageSize(width, height float64) *Params

SetImageSize : Set image size.

func (*Params) SetValuesBy2DArray

func (p *Params) SetValuesBy2DArray(values [][]interface{}) *Params

SetValuesBy2DArray : Put values using 2 dimensional array.

func (*Params) SetValuesByObject

func (p *Params) SetValuesByObject(values []ValueObject) *Params

SetValuesByObject : Put values using object.

func (*Params) ShowAPIResponse

func (p *Params) ShowAPIResponse(f bool) *Params

ShowAPIResponse : Show responses from Docs API.

func (*Params) TableIndex

func (p *Params) TableIndex(tableIndex int) *Params

TableIndex : Set table index. If there are 5 tables in Document, tableIndex of 3rd table is 2.

func (*Params) TableOnly

func (p *Params) TableOnly(tableOnly bool) *Params

TableOnly : Whether searches only the tables.

type Result

type Result struct {
	Tables           []Table       `json:"tables,omitempty"`
	Values           [][]string    `json:"values,omitempty"`
	ResponseFromAPIs []interface{} `json:"responseFromAPIs,omitempty"`
	LibraryVersion   string        `json:"libraryVersion"`
	Message          string        `json:"message,omitempty"`
}

Result : Result from gdoctableapp

type Table

type Table struct {
	Index         int64      `json:"index"`
	Values        [][]string `json:"values"`
	TablePosition struct {
		StartIndex int64 `json:"startIndex"`
		EndIndex   int64 `json:"endIndex"`
	}
}

Table : Retrieved table.

type ValueObject

type ValueObject struct {
	Range struct {
		StartRowIndex    int64 `json:"startRowIndex"`
		StartColumnIndex int64 `json:"startColumnIndex"`
	} `json:"range"`
	Values [][]interface{} `json:"values"`
}

ValueObject : Object for putting values.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL