opensearch

package module
v4.0.0-7 Latest Latest
Warning

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

Go to latest
Published: Jun 17, 2026 License: MIT Imports: 9 Imported by: 0

README

opensearch-go

Go Reference Go Report Card License

Go client for OpenSearch and OpenSearch-compatible clusters.

⚠️ v4 is a complete rewrite of the library. Expect breaking changes from v3. Review the v4 migration guide before upgrading. Import paths change from v3 to v4, and most APIs have new signatures.

  • Type-safe interfaces for all 16 OpenSearch API groups
  • Fluent query-builder DSL (querydsl/) — no raw JSON required
  • Struct-literal configuration (value semantics, no pointer surprise)
  • Built on resty — middleware, retries, timeouts
  • Structured logging via logrus
  • OpenTelemetry tracing middleware (trace/opentelemetry/)

Install

go get github.com/disaster37/opensearch/v4

Requires Go 1.18+ (uses generics in api/ and querydsl/).

Quick Start

package main

import (
    "context"
    "log"

    os "github.com/disaster37/opensearch/v4"
    "github.com/sirupsen/logrus"
)

func main() {
    logger := logrus.NewEntry(logrus.StandardLogger())

    client, err := os.New(&os.Config{
        URL:      "https://localhost:9200",
        Username: "admin",
        Password: "admin",
    }, logger)
    if err != nil {
        log.Fatal(err)
    }

    ctx := context.Background()

    // Index a document
    doc := map[string]any{
        "name":  "OpenSearch",
        "stars": 10000,
    }
    resp, err := client.Document().Index(ctx, "repos", nil, doc, nil)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Indexed %s/%s\n", resp.Index, resp.ID)
}

Using the Query DSL

Build type-safe queries without raw JSON maps:

import "github.com/disaster37/opensearch/v4/querydsl"

// Bool query with must/should/filter clauses
query := querydsl.Bool{
    Must: []querydsl.Query{
        querydsl.Match{"status": "published"},
    },
    Should: []querydsl.Query{
        querydsl.Term{"Field": "featured", "Value": true, "Boost": ptr(1.5)},
    },
    Filter: []querydsl.Query{
        querydsl.Range{
            Field:     "date",
            Gte:       "2024-01-01",
            Format:    "yyyy-MM-dd",
        },
    },
    MinimumShouldMatch: "1",
}
Aggregations
// Terms aggregation with sub-aggregation
agg := querydsl.TermsAggregation("genre").
    Size(20).
    SubAggregation("avg_rating", querydsl.AvgAggregation("rating"))

// Date histogram for time-series
histogram := querydsl.DateHistogramAggregation("timestamp").
    CalendarInterval("1d").
    MinDocCount(0).
    SubAggregation("total_sales", querydsl.SumAggregation("amount"))

Available Services

All services are accessed through the Client interface:

Service Accessor Description
Document client.Document() Index, Get, Update, Delete, Bulk, Reindex
Search client.Search() Search, Count, Scroll, MultiSearch, PIT
Indices client.Indices() Create, Delete, Settings, Mappings, Templates
Cluster client.Cluster() Health, State, Stats, Settings
Nodes client.Nodes() Info, Stats
Cat client.Cat() Human-readable cluster info
Ingest client.Ingest() Pipeline management
Snapshot client.Snapshot() Snapshots and repositories
Tasks client.Tasks() Task management
Script client.Script() Stored scripts
Security client.Security() Users, roles, tenants (Security plugin)
ISM client.ISM() Index State Management
SM client.SM() Snapshot Management
Alerting client.Alerting() Monitors and alerts
Transform client.Transform() Transform jobs
CCR client.CCR() Cross-Cluster Replication

Error Handling

All API errors are returned as *types.OpenSearchError:

result, err := client.Document().Get(ctx, "my-index", "doc-id", nil)
if err != nil {
    if os.IsNotFound(err) {
        log.Println("Document not found")
    } else if os.IsConflict(err) {
        log.Println("Version conflict — retry or refresh")
    } else {
        var osErr *types.OpenSearchError
        if errors.As(err, &osErr) {
            log.Printf("OpenSearch error %d: %s\n",
                osErr.Status, osErr.Details.Reason)
        } else {
            log.Fatal(err)
        }
    }
}

Optimistic Concurrency

Use DocumentVersion to prevent overwrites:

// First fetch
doc, err := client.Document().Get(ctx, "my-index", "doc-id", nil)

// Update with version
_, err = client.Document().Update(ctx, "my-index", "doc-id", newDoc,
    &os.DocumentVersion{
        SeqNo:       doc.SeqNo,
        PrimaryTerm: doc.PrimaryTerm,
    },
    nil)

Configuration

import (
    "time"

    os "github.com/disaster37/opensearch/v4"
)

client, err := os.New(&os.Config{
    URL:           "https://localhost:9200",
    Username:      "admin",
    Password:      "admin",
    TLSSkipVerify: false, // production: always false
    CACert:        caCertPEM,
    Timeout:       30 * time.Second,
}, logger)

OpenTelemetry Tracing

Wrap the client with the OTel trace middleware:

import (
    otel "github.com/disaster37/opensearch/v4/trace/opentelemetry"
)

client, _ := os.New(cfg, logger)
otel.WrapClient(client.RestyClient(), otel.WithServiceName("my-service"))

Testing

go test ./...

Contributing

See CONTRIBUTING.md.

License

MIT — see LICENSE.

Project Structure

opensearch-go/
├── client.go          # Client interface + New()
├── common.go          # Re-exported type aliases
├── errors.go          # Error helpers
├── types/             # Common types (ShardsInfo, OpenSearchError, etc.)
├── api/               # 16 service interfaces + models
│   ├── document_service.go
│   ├── search_service.go
│   ├── indices_service.go
│   └── ...
├── querydsl/          # Query and aggregation builder DSL
│   ├── search_queries_*.go
│   ├── search_aggs_*.go
│   └── search_source.go
└── trace/opentelemetry/

Documentation

Overview

Package opensearch provides a Go client for the OpenSearch REST API.

This client provides a type-safe, ergonomic interface for interacting with OpenSearch clusters. It supports all major OpenSearch features including:

  • Document CRUD operations
  • Full-text search and complex queries
  • Aggregations and analytics
  • Index lifecycle management
  • Cross-cluster replication
  • Security and access control
  • And many more features

Quick Start

Create a client:

import (
    "github.com/disaster37/opensearch/v4"
    "github.com/sirupsen/logrus"
)

func main() {
    logger := logrus.NewEntry(logrus.StandardLogger())

    client, err := opensearch.New(&opensearch.Config{
        URL:           "https://localhost:9200",
        Username:      "admin",
        Password:      "admin",
        TLSSkipVerify: true, // For development only
    }, logger)
    if err != nil {
        log.Fatal(err)
    }

    // Use the client to interact with OpenSearch
    // ...
}

Package Organization

The client is organized into several sub-packages:

  • types: Common types and error handling
  • api: Service interfaces for OpenSearch REST APIs
  • querydsl: Query and aggregation builder DSL

Most users will only need to import the root package. The sub-packages are automatically re-exported for convenience.

Service Access

Access OpenSearch features through service methods on the Client interface:

// Document operations
client.Document().Index(ctx, "my-index", doc, "doc-id", nil)
client.Document().Get(ctx, "my-index", "doc-id", nil)
client.Document().Delete(ctx, "my-index", "doc-id", nil)

// Search operations
client.Search().Search(ctx, []string{"my-index"}, query, nil)
client.Search().Count(ctx, []string{"my-index"}, query)

// Index operations
client.Indices().Create(ctx, "my-index", nil)
client.Indices().Delete(ctx, "my-index", nil)

// Cluster operations
client.Cluster().Health(ctx, []string{"my-index"}, nil)
client.Cluster().Stats(ctx, nil)

Using the Query DSL

For complex queries, use the querydsl sub-package to build type-safe queries:

import "github.com/disaster37/opensearch/v4/querydsl"

// Build a bool query
query := querydsl.BoolQuery().
    Must(querydsl.MatchQuery("status", "published")).
    Filter(querydsl.RangeQuery("date").Gte("2024-01-01")).
    Should(querydsl.TermQuery("featured", true))

// Execute the search
result, err := client.Search().Search(ctx, []string{"my-index"}, query.JSON(), nil)

Error Handling

All operations can return an OpenSearchError. Use the helper functions to check for specific error conditions:

result, err := client.Document().Get(ctx, "my-index", "doc-id", nil)
if err != nil {
    if os.IsNotFound(err) {
        log.Println("Document not found")
    } else if os.IsConflict(err) {
        log.Println("Version conflict")
    } else {
        log.Fatal(err)
    }
}

Type Aliases

For convenience, common types from the types sub-package are re-exported in the root package:

  • OpenSearchError
  • AcknowledgedResponse
  • ShardsInfo
  • BroadcastResponse
  • DocumentVersion

You can use these directly without importing the types package:

version := os.DocumentVersion{SeqNo: 1, PrimaryTerm: 1}
client.Document().Index(ctx, "my-index", doc, "doc-id", &version)

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultRetryConditions

func DefaultRetryConditions() []resty.RetryConditionFunc

DefaultRetryConditions returns the default retry conditions that handle: - Network errors (no response received) - 429 Too Many Requests status code - 5xx server error status codes (excluding 501 Not Implemented)

These conditions are automatically applied when RetryCount > 0, but you can use this function to build custom retry logic that includes the defaults.

func IsConflict

func IsConflict(err error) bool

IsConflict returns true when err is an OpenSearchError with status 409. This typically indicates a version conflict during an optimistic concurrency operation (see DocumentVersion).

func IsNotFound

func IsNotFound(err error) bool

IsNotFound returns true when err is an OpenSearchError with status 404. Use this to branch on "document or index not found" cases without inspecting the error details manually:

res, err := client.Document().Get(ctx, "my-index", "doc-id", nil)
if err != nil {
    if opensearch.IsNotFound(err) {
        log.Printf("document missing; creating fresh copy")
        // insert new document
    } else {
        return err
    }
}

_ = res

Example
package main

import (
	"context"
	"fmt"
	"log"

	opensearch "github.com/disaster37/opensearch/v4"
	"github.com/disaster37/opensearch/v4/api"
	"github.com/sirupsen/logrus"
)

func main() {
	logger := logrus.NewEntry(logrus.StandardLogger())
	client, err := opensearch.New(&opensearch.Config{
		URL: "http://localhost:9200",
	}, logger)
	if err != nil {
		log.Fatal(err)
	}

	_, err = client.Document().Get(context.Background(), &api.GetRequest{
		Index: "my-index",
		Id:    "missing-id",
	})
	if err != nil {
		if opensearch.IsNotFound(err) {
			fmt.Println("document not found; creating fresh one")
		} else if opensearch.IsConflict(err) {
			fmt.Println("version conflict: refetch and retry")
		} else {
			log.Fatal(err)
		}
	}
}

func PITSearchRetryConditions

func PITSearchRetryConditions() []resty.RetryConditionFunc

PITSearchRetryConditions returns retry conditions specifically optimized for Point-in-Time (PIT) search queries and other long-running OpenSearch operations. Includes default conditions plus additional OpenSearch-specific scenarios.

Types

type AcknowledgedResponse

type AcknowledgedResponse = types.AcknowledgedResponse

AcknowledgedResponse is the common response for operations that are simply acknowledged or rejected. Returned by most indices and cluster operations.

type BroadcastResponse

type BroadcastResponse = types.BroadcastResponse

BroadcastResponse is the common response for broadcast-style operations (operations applied to all shards in a set of indices).

type Client

type Client interface {
	// RestyClient returns the underlying resty client for middleware
	// configuration (e.g., adding OpenTelemetry tracing).
	RestyClient() *resty.Client

	Document() api.DocumentService
	Search() api.SearchService
	Indices() api.IndicesService
	Cluster() api.ClusterService
	Nodes() api.NodesService
	Cat() api.CatService
	Ingest() api.IngestService
	Snapshot() api.SnapshotService
	Tasks() api.TasksService
	Script() api.ScriptService
	Security() api.SecurityService
	ISM() api.IsmService
	SM() api.SmService
	Alerting() api.AlertingService
	Transform() api.TransformService
	CCR() api.CcrService
	Info() api.InfoService
	Rollup() api.RollupService
	ML() api.MlService
	SQL() api.SqlService
	AD() api.AdService
	AsyncSearch() api.AsyncSearchService
	KNN() api.KnnService
	Neural() api.NeuralService
}

Client is the main entry point for all OpenSearch operations.

Obtain a Client via New. All API groups are accessible as methods on this interface:

client, err := opensearch.New(&opensearch.Config{...}, logger)

client.Document().Index(ctx, "idx", doc, "id", nil)
client.Search().Search(ctx, []string{"idx"}, query, nil)
client.Indices().Create(ctx, "new-idx", settings)
client.Cluster().Health(ctx, nil, nil)

func New

func New(cfg *Config, logger *logrus.Entry) (Client, error)

New creates a new Client connecting to an OpenSearch cluster.

The returned client builds a resty HTTP client internally, applies TLS and authentication from cfg, and instantiates all 18 service interfaces.

Example with retry configuration for long-running queries (like Point-in-Time searches):

client, err := opensearch.New(&opensearch.Config{
    URL:      "https://localhost:9200",
    Username: "admin",
    Password: "admin",
    RetryCount: 3,
    RetryWaitTime: 500 * time.Millisecond,
    RetryMaxWaitTime: 5 * time.Second,
    RetryConditions: opensearch.PITSearchRetryConditions(),
}, logrus.NewEntry(logrus.StandardLogger()))
Example
package main

import (
	"context"
	"fmt"
	"log"

	opensearch "github.com/disaster37/opensearch/v4"
	"github.com/sirupsen/logrus"
)

func main() {
	logger := logrus.NewEntry(logrus.StandardLogger())

	client, err := opensearch.New(&opensearch.Config{
		URL:      "https://localhost:9200",
		Username: "admin",
		Password: "admin",
	}, logger)
	if err != nil {
		log.Fatal(err)
	}

	health, err := client.Cluster().Health(
		context.Background(),
		nil,
	)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("cluster %s: %s\n", health.ClusterName, health.Status)
}
Example (DefaultRetry)
package main

import (
	"fmt"
	"log"
	"time"

	"github.com/disaster37/opensearch/v4"
	"github.com/sirupsen/logrus"
)

func main() {
	logger := logrus.NewEntry(logrus.New())

	// Example: Use default retry conditions (network errors, 429, 5xx)
	client, err := opensearch.New(&opensearch.Config{
		URL:              "https://localhost:9200",
		Username:         "admin",
		Password:         "admin",
		RetryCount:       2,
		RetryWaitTime:    100 * time.Millisecond,
		RetryMaxWaitTime: 2 * time.Second,
		RetryConditions:  opensearch.DefaultRetryConditions(),
	}, logger)
	if err != nil {
		log.Fatal(err)
	}

	_ = client
	fmt.Println("Client with default retry conditions")
}
Output:
Client with default retry conditions
Example (WithRetry)
package main

import (
	"fmt"
	"log"
	"time"

	"github.com/disaster37/opensearch/v4"
	"github.com/sirupsen/logrus"
)

func main() {
	logger := logrus.NewEntry(logrus.New())

	// Example: Configure client with retry for long-running queries like PIT searches
	client, err := opensearch.New(&opensearch.Config{
		URL:              "https://localhost:9200",
		Username:         "admin",
		Password:         "admin",
		RetryCount:       3,
		RetryWaitTime:    500 * time.Millisecond,
		RetryMaxWaitTime: 5 * time.Second,
		RetryConditions:  opensearch.PITSearchRetryConditions(),
	}, logger)
	if err != nil {
		log.Fatal(err)
	}

	// The client will now automatically retry failed requests up to 3 times
	// with exponential backoff between 500ms and 5s, including OpenSearch-specific
	// transient errors like search_phase_execution_exception.

	_ = client
	fmt.Println("Client configured with retry settings")
}
Output:
Client configured with retry settings

type CommonParams

type CommonParams = types.CommonParams

CommonParams are the query parameters common to all OpenSearch API requests. They are forwarded to all service methods via [DocumentService], [SearchService], etc.

type Config

type Config struct {
	// URL is the OpenSearch cluster address. Required.
	// Examples: "https://localhost:9200", "http://cluster:9200"
	URL string

	// Username for HTTP basic authentication.
	Username string

	// Password for HTTP basic authentication.
	Password string

	// TLSSkipVerify disables TLS certificate verification.
	// WARNING: Only set to true for development. For production, use CACert.
	TLSSkipVerify bool

	// CACert is a PEM-encoded CA certificate used to verify the server.
	// If empty and TLSSkipVerify is false, the system cert pool is used.
	CACert []byte

	// Timeout is the HTTP request timeout. Zero means no timeout.
	Timeout time.Duration

	// IdleConnTimeout is the maximum amount of time an idle (keep-alive)
	// connection is kept in the pool before being closed.
	// Set it below the keep-alive timeout of any intermediate proxy
	// (e.g. an nginx ingress, default 75s) to avoid reusing a connection
	// the peer has already closed, which surfaces as
	// "connection reset by peer". Zero keeps the Go default (90s).
	IdleConnTimeout time.Duration

	// DisableHTTP2 forces the client to use HTTP/1.1 instead of negotiating
	// HTTP/2 via TLS ALPN.
	//
	// HTTP/2 multiplexes every request onto a single TCP connection. When an
	// intermediary (load balancer, firewall, ingress front proxy) resets that
	// connection, all in-flight streams fail at once and surface as
	// "connection reset by peer". For sequential workloads (e.g. paginated
	// PIT/search_after exports) HTTP/2 brings no benefit, so forcing HTTP/1.1
	// isolates each request and makes transient resets cheap to retry.
	DisableHTTP2 bool

	// RetryCount is the maximum number of retry attempts for failed requests.
	// Default is 0 (no retries). Set to a positive integer to enable retries.
	RetryCount int

	// RetryWaitTime is the minimum wait time between retry attempts.
	// Default is 100ms if not specified.
	RetryWaitTime time.Duration

	// RetryMaxWaitTime is the maximum wait time between retry attempts.
	// Default is 2s if not specified.
	RetryMaxWaitTime time.Duration

	// RetryConditions are custom functions that determine if a request should be retried.
	// By default, resty retries on network errors, 429 Too Many Requests, and 5xx server errors.
	// Add custom conditions to extend the default behavior.
	RetryConditions []resty.RetryConditionFunc
}

Config holds configuration for the OpenSearch client.

type DefaultClient

type DefaultClient struct {
	// contains filtered or unexported fields
}

DefaultClient is the default Client implementation returned by New.

func (*DefaultClient) AD

func (c *DefaultClient) AD() api.AdService

func (*DefaultClient) Alerting

func (c *DefaultClient) Alerting() api.AlertingService

func (*DefaultClient) AsyncSearch

func (c *DefaultClient) AsyncSearch() api.AsyncSearchService

func (*DefaultClient) CCR

func (c *DefaultClient) CCR() api.CcrService

func (*DefaultClient) Cat

func (c *DefaultClient) Cat() api.CatService

func (*DefaultClient) Cluster

func (c *DefaultClient) Cluster() api.ClusterService

func (*DefaultClient) Document

func (c *DefaultClient) Document() api.DocumentService

func (*DefaultClient) ISM

func (c *DefaultClient) ISM() api.IsmService

func (*DefaultClient) Indices

func (c *DefaultClient) Indices() api.IndicesService

func (*DefaultClient) Info

func (c *DefaultClient) Info() api.InfoService

func (*DefaultClient) Ingest

func (c *DefaultClient) Ingest() api.IngestService

func (*DefaultClient) KNN

func (c *DefaultClient) KNN() api.KnnService

func (*DefaultClient) ML

func (c *DefaultClient) ML() api.MlService

func (*DefaultClient) Neural

func (c *DefaultClient) Neural() api.NeuralService

func (*DefaultClient) Nodes

func (c *DefaultClient) Nodes() api.NodesService

func (*DefaultClient) RestyClient

func (c *DefaultClient) RestyClient() *resty.Client

func (*DefaultClient) Rollup

func (c *DefaultClient) Rollup() api.RollupService

func (*DefaultClient) SM

func (c *DefaultClient) SM() api.SmService

func (*DefaultClient) SQL

func (c *DefaultClient) SQL() api.SqlService

func (*DefaultClient) Script

func (c *DefaultClient) Script() api.ScriptService

func (*DefaultClient) Search

func (c *DefaultClient) Search() api.SearchService

func (*DefaultClient) Security

func (c *DefaultClient) Security() api.SecurityService

func (*DefaultClient) Snapshot

func (c *DefaultClient) Snapshot() api.SnapshotService

func (*DefaultClient) Tasks

func (c *DefaultClient) Tasks() api.TasksService

func (*DefaultClient) Transform

func (c *DefaultClient) Transform() api.TransformService

type DocumentVersion

type DocumentVersion = types.DocumentVersion

DocumentVersion carries the seq_no and primary_term used for optimistic concurrency control in document write operations.

Pass a DocumentVersion to [DocumentService.Index], [DocumentService.Update], or [DocumentService.Delete] to fail the operation if the document has been modified since it was last fetched.

Example
package main

import (
	"context"
	"fmt"
	"log"

	opensearch "github.com/disaster37/opensearch/v4"
	"github.com/disaster37/opensearch/v4/api"
	"github.com/sirupsen/logrus"
)

func main() {
	logger := logrus.NewEntry(logrus.StandardLogger())
	client, err := opensearch.New(&opensearch.Config{
		URL: "http://localhost:9200",
	}, logger)
	if err != nil {
		log.Fatal(err)
	}

	doc, err := client.Document().Get(context.Background(), &api.GetRequest{
		Index: "my-index",
		Id:    "doc-1",
	})
	if err != nil {
		log.Fatal(err)
	}

	newSource := map[string]any{"name": "updated"}

	if doc.SeqNo == nil || doc.PrimaryTerm == nil {
		log.Fatal("document has no seq_no/primary_term; refetch")
	}
	_, err = client.Document().Update(
		context.Background(),
		&api.UpdateRequest{
			Index: "my-index",
			Id:    "doc-1",
			Body:  newSource,
			Params: &api.UpdateParams{
				IfSeqNo:       doc.SeqNo,
				IfPrimaryTerm: doc.PrimaryTerm,
			},
		},
	)
	if err != nil {
		if opensearch.IsConflict(err) {
			fmt.Println("conflict; retry the read-modify-write cycle")
		} else {
			log.Fatal(err)
		}
	}
}

type ErrorDetails

type ErrorDetails = types.OpenSearchErrorDetails

ErrorDetails is an alias for OpenSearchErrorDetails for backward compatibility.

type FailedNodeException

type FailedNodeException = types.FailedNodeException

FailedNodeException represents a failure that occurred on a specific node during a multi-node operation (e.g., [NodesService.Info]).

type ListResponse

type ListResponse[T any] = types.ListResponse[T]

ListResponse is a generic list-response wrapper used by plugin services (ISM, SM, Alerting, Transform).

type OpenSearchError

type OpenSearchError = types.OpenSearchError

OpenSearchError is the structured error returned by OpenSearch when a request fails. Use IsNotFound and IsConflict for common checks.

type OpenSearchErrorDetails

type OpenSearchErrorDetails = types.OpenSearchErrorDetails

OpenSearchErrorDetails contains the detailed error payload of an OpenSearchError, including root causes and stack traces for scripting errors.

type ScriptErrorPosition

type ScriptErrorPosition = types.ScriptErrorPosition

ScriptErrorPosition describes where in a script an error occurred (used inside OpenSearchErrorDetails for scripting errors).

type ShardOperationFailedException

type ShardOperationFailedException = types.ShardOperationFailedException

ShardOperationFailedException describes a single shard failure in a ShardsInfo response.

type ShardsInfo

type ShardsInfo = types.ShardsInfo

ShardsInfo represents the "_shards" section of most OpenSearch responses. Reports the number of successful, failed, and pending shard operations.

type UnixMilliTime

type UnixMilliTime = types.UnixMilliTime

UnixMilliTime is a time.Time that serializes to/from Unix milliseconds. Used by the CCR and plugin APIs to represent timestamps.

Directories

Path Synopsis
Package api provides service interfaces for interacting with OpenSearch REST API endpoints.
Package api provides service interfaces for interacting with OpenSearch REST API endpoints.
Package querydsl provides a programmatic, type-safe query builder DSL for constructing OpenSearch queries and aggregations.
Package querydsl provides a programmatic, type-safe query builder DSL for constructing OpenSearch queries and aggregations.
trace
Package types provides the common types and error handling used throughout the OpenSearch client.
Package types provides the common types and error handling used throughout the OpenSearch client.

Jump to

Keyboard shortcuts

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