API reference
Search & OCR API
Full-text search across your notes, everything Harbor has read out of your attachments, and their file names — plus the exact on-page coordinates of every match in a photo, scan or PDF, so you can draw the highlights yourself.
Harbor search runs against a per-user FTS5 index, ranked by BM25. File names
are the one thing matched outside it, directly against the files. It covers two
endpoints: GET /search runs a query and returns ranked note and attachment
hits with snippets — an attachment is found by the words inside it (text
read from images and PDFs, the contents of
Word, Excel, PowerPoint and text files,
and recording transcripts and summaries) or by its file name — and
GET /search/coordinates returns the on-page bounding boxes of matched words
inside a single attachment — the endpoint behind the yellow inline OCR
highlights you see in Harbor search.
Both endpoints require a bearer token with the
search scope and only ever see the
authenticated user’s own data.
Encryption never leaks through search. Encrypted notes and encrypted attachments (and any attachment owned by an encrypted note) are never indexed, OCR’d, or returned by either endpoint. The index stores no plaintext for them, so they cannot surface through results, snippets, or coordinates. Notebook names stay plaintext, so the
notebook:filter keeps working.
The query grammar
The q parameter is an Evernote-style query string. Tokens are
whitespace-separated:
| Token | Meaning |
|---|---|
bareword | Free-text term, matched against a note’s title, body, tag names and source URL, and the words inside an attachment and its file name. Multiple free-text terms AND together. |
term* | A trailing * is a prefix match — recei* matches receive, receipt, … Against a file name the * is dropped and recei matches anywhere in the name. |
"exact phrase" | Quoted words match consecutively and in order. Against a file name a phrase is one substring, spaces included ("Q3 Budget"). |
-token | Negates any token (free-text, phrase, tag:, notebook:, intitle:, meta:). A negated word or phrase also drops a file-name match whose name contains it. |
tag:VALUE | Note carries this tag. Values are folded — lower-cased, diacritics stripped, whitespace → _. Use tag:"two words" for multi-word tags. A tag: matching no tag yields no results. |
notebook:VALUE | Note is in this notebook, matched by exact id first, else case-insensitive name. No match yields no results. |
intitle:VALUE | Term must appear in the note title. Notes only — attachments have no title, so any intitle: term returns no attachment hits. |
resource:RTYPE | Note owns a live, non-encrypted attachment of this type: image, pdf, audio, application, or any. |
created:RANGE | Filter by creation date (forms below). |
updated:RANGE | Filter by last-updated date. |
meta:KEY / meta:KEY=VALUE | Note has this metadata key, or the key equals the value. See searching by metadata. |
Date ranges for created: / updated: come in three forms, all evaluated
in UTC with inclusive bounds:
YYYYMMDD— a single day, e.g.created:20260615YYYYMMDD..YYYYMMDD— a span, e.g.updated:20260101..20260131day-N— the last N days including today, e.g.updated:day-7
A few semantics worth knowing:
- Negation always subtracts.
report -draftis thereportset minus anything also matchingdraft. A pure-negation query like-spamreturns everything except matches — not nothing. - Filters-only queries are valid.
tag:finance resource:pdfwith no free-text term runs the structural filters on their own. - You can’t inject FTS operators. User terms are quoted and escaped before they reach FTS5, so input is always treated as a literal term or phrase.
- A query that parses to nothing matchable at all — empty, whitespace, or a
stray operator with no value — returns
422 validation_failed.
Searching by metadata
meta: filters notes by their metadata, the JSON
labels your integration puts on them through the API. The Harbor apps don’t
advertise it. It’s there for you.
| Query | Finds notes where |
|---|---|
meta:favorite=true | the top-level key favorite equals true. |
meta:album="Summer 2026" | album equals Summer 2026. Quote a value with spaces. |
meta:album | the key album exists, whatever its value (null included). |
-meta:favorite=true | anything but that. |
beach meta:favorite=true | “beach” matches and favorite is true. |
Several meta: operators AND together. On their own, with no free text, they
list every matching note, just like tag:.
The key is split from the value at the first =, and both are trimmed.
meta:album= means “equals the empty string”. Keys are case-sensitive;
values mostly aren’t. A string value ignores the case of A–Z, true and
false match booleans, a number matches the same number, and an array matches
if any element does. The full rules are in
the matching rule, which the
list filters share.
An attachment hit is judged by its note’s metadata, the same as tag:. A file
that belongs to no note never matches meta: and is never excluded by
-meta:.
A meta: with no key, or a key with anything outside A-Z a-z 0-9 _ -, is a
422 validation_failed:
{
"error": {
"code": "validation_failed",
"message": "The request was invalid.",
"details": {
"meta": "expected meta:KEY or meta:KEY=VALUE, where KEY is 1-64 characters of A-Z, a-z, 0-9, '_' or '-'"
}
}
}
curl -G "https://app.harbor.my/api/v1/search" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode 'q=meta:album="Summer 2026" -meta:favorite=true' \
--data-urlencode 'types=note'
File names
A free-text query also finds attachments by their file name, extension
included. xlsx finds an Excel file and the note it’s in, whatever the
words inside it. Names aren’t in the FTS index. They’re matched against
the files directly, so a renamed file is found by its new name right away.
- What matches. A file matches by name when every positive free-text term
is somewhere in its file name. A quoted phrase is one term, spaces included.
budg*is matched asbudg.%,_and\match themselves. - Case. Only
A–Zfold.XLSXfindsQ3 Budget.xlsx.ÄrztefindsÄrzte Liste.pdf, butärztedoesn’t. Accents don’t fold either:ubersichtdoesn’t findÜbersicht.pdf. - Negation.
-wordor-"phrase"drops a name match whose file name contains it or whose words match it. - Which files. A name hit has to be a live, unencrypted file that isn’t in any encrypted note, owned by a live note outside the trash. When several notes hold the file, the most recently updated one is the owner. A file in no note, or only in the trash, can still be a text hit, but never a name hit.
- Filters go by the owning note, the same as for a text hit:
tag:,notebook:,meta:,resource:andnotebook_id.created:andupdated:read the file’s own dates. Anyintitle:term returns no files. - No positive term, no name match. A query of only operators or only
negations (
resource:pdf,-xlsx) never matches by name. - One hit per file. A file the query matches by the words inside it comes back as a text hit, with its snippet, and never as a name hit too.
- The hit has
match: "name", an emptysnippet,highlights: []andscore: 0. Nothing inside the file matched, so show no excerpt and no In file label, and open the owning note when it’s picked.
Q3 Budget.xlsx and… | Name match? |
|---|---|
xlsx, budget, budget xlsx, budg* | ✅ |
"Q3 Budget", "Q3 Budget.xlsx", XLSX | ✅ |
budget pdf | ❌ — pdf isn’t in the name |
budget -xlsx | ❌ — the negated term is in the name |
-xlsx, resource:application | ❌ — no positive free-text term |
Search notes and attachments
GET /api/v1/search · scope: search
Run a query and return the matching note and/or attachment hits, ranked by relevance.
| Field | Type | Required | Description |
|---|---|---|---|
q | string | Yes | The query string (grammar above). Empty, whitespace, or unmatchable → 422. |
types | string (CSV) | No | Hit types to return; each value note or attachment. Default note,attachment. |
notebook_id | string | No | Hard filter to one notebook, ANDed with any notebook: operator in q. |
limit | integer | No | Default 100, hard cap 500 (clamped, not errored). |
offset | integer | No | Default 0. Invalid values fall back to 0. |
order | string | No | relevance (default), updated_at, -updated_at, created_at, -created_at. |
snippet | boolean | No | Default true. false skips snippet/highlight generation — snippet comes back empty and highlights as []. |
curl -G "https://app.harbor.my/api/v1/search" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode 'q=budget tag:finance -draft created:day-30' \
--data-urlencode 'types=note,attachment' \
--data-urlencode 'limit=20'
The response is the standard { data, paging } collection envelope. Each
element is a note hit or an attachment hit:
{
"data": [
{
"type": "note",
"note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
"notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
"title": "Quarterly plan",
"snippet": "…the <em>budget</em> for Q3…",
"highlights": ["budget"],
"score": 8.42,
"tags": ["finance", "planning"],
"created_at": 1749000000000,
"updated_at": 1750000000000
},
{
"type": "attachment",
"resource_id": "7d41e0c2-3b8a-4f19-a6d2-5c0e9b7f1a84",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
"note_title": "Quarterly plan",
"notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"filename": "Q3 Budget.xlsx",
"snippet": "",
"highlights": [],
"score": 0,
"has_coordinates": false,
"match": "name",
"created_at": 1749000000000,
"updated_at": 1750000000000
},
{
"type": "attachment",
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"note_id": "9c2e7b10-4f6d-4a2e-9b1c-3d8e5f7a0c21",
"note_title": "Quarterly plan",
"notebook_id": "5b1f2c9a-8e3d-4b7f-a1c6-2f9d0e4b8a53",
"mime": "application/pdf",
"filename": "invoice.pdf",
"snippet": "…total <em>budget</em> 12,500…",
"highlights": ["budget"],
"score": 3.07,
"has_coordinates": true,
"match": "text",
"created_at": 1749000000000,
"updated_at": 1750000000000
}
],
"paging": { "limit": 100, "offset": 0, "total": 3, "has_more": false }
}
Fields on every hit:
| Field | Type | Description |
|---|---|---|
type | string | note or attachment. |
notebook_id | string | The containing notebook. |
snippet | string | HTML-escaped excerpt with matched terms wrapped in <em>…</em>. Empty with snippet=false, and for a file-name match. |
highlights | array | Deduped, lower-cased matched terms — always an array, never null. Re-highlight without parsing the snippet. [] for a file-name match. |
score | number | BM25 relevance score. 0 for a file-name match. |
created_at | integer | UTC epoch milliseconds. |
updated_at | integer | UTC epoch milliseconds. |
Note hits add note_id, title, and tags. Attachment hits add:
| Field | Type | Description |
|---|---|---|
resource_id | string | The attachment’s resource id. |
hash | string | The sha256 content address. Fetch the file’s bytes and key highlight overlays by hash, not resource_id — see the Files API. |
note_id | string | The owning note. |
note_title | string | The parent note’s title, so a UI can render “a file within a note” (note title primary, filename beneath). "" when the attachment is in no live note. Always plaintext — encrypted-note attachments are excluded from search entirely. |
mime | string | The attachment’s MIME type. |
filename | string | The original filename. |
has_coordinates | boolean | true means OCR word boxes exist for this attachment, so you can call GET /search/coordinates for highlight rectangles. A name hit can be true too, but nothing in the file matched, so there’s nothing to highlight. Always false for a text or Office file: its words are read without word boxes. |
match | string | How the file was found. text: through the index, by the words inside the file — text read from images and PDFs, the contents of Word, Excel, PowerPoint and text files, and recording transcripts and summaries — or by the filters alone in a filters-only query. Harbor’s web, desktop and mobile apps mark a text hit In file. name: by its file name only, with no label. Treat a missing match as text. |
Ranking. order=relevance (the default) ranks in fixed tiers, then by BM25
inside each tier:
- Notes whose title has every positive term.
- Notes clipped from the site you searched: the source host has every positive free-text term.
- Notes that match only through their body or tags.
- Attachments, always last. File-name matches come first, newest file first, then text matches by score.
Every hit in a tier ranks above every hit in the next one, whatever the raw
scores. Inside a tier, note matches score across five weighted columns: title
10.0, body 1.0, tags 4.0, source URL 6.0 and source host 6.0. Ties
break by updated_at descending, then id ascending, for stable paging. The
score you get back is the raw BM25 value, so it doesn’t show the tier.
A timestamp order is a plain date sort. Notes and files mix by date, and
neither the tiers nor the name-first rule apply.
The result window is bounded by the 500 hard cap — total never exceeds 500 —
and snippets/highlights are computed only for the requested page.
Errors
422 validation_failed— empty/whitespace/unmatchableq, a malformed operator value (bad date range, unknownresource:type, ameta:with a bad key), an unknowntypesvalue, or an unknownordervalue.detailsnames the offending field.limit > 500is clamped, not errored.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
# Search notes and attachments: free text + tag + date-range operators.
resp = requests.get(
f"{BASE}/search",
headers=HEADERS,
params={
"q": "budget tag:finance -draft created:day-30",
"types": "note,attachment",
"limit": 20,
},
)
resp.raise_for_status()
for hit in resp.json()["data"]:
if hit["type"] == "note":
print(f"note: {hit['title']} (score {hit['score']})")
elif hit.get("match") == "name":
# Found by its file name: no snippet, nothing to highlight.
print(f"file name: {hit['filename']} in \"{hit['note_title']}\"")
else:
# Attachment hit — a match on the words inside a file.
print(f"attachment: {hit['filename']} in \"{hit['note_title']}\"")
if hit["has_coordinates"]:
print(" has word boxes — /search/coordinates works")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
// Search notes and attachments: free text + tag + date-range operators.
const params = new URLSearchParams({
q: "budget tag:finance -draft created:day-30",
types: "note,attachment",
limit: "20",
});
const res = await fetch(`${BASE}/search?${params}`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!res.ok) throw new Error(`search failed: ${res.status}`);
const { data, paging } = await res.json();
for (const hit of data) {
if (hit.type === "note") {
console.log(`note: ${hit.title} (score ${hit.score})`);
} else if (hit.match === "name") {
// Found by its file name: no snippet, nothing to highlight.
console.log(`file name: ${hit.filename} in "${hit.note_title}"`);
} else {
console.log(`attachment: ${hit.filename} in "${hit.note_title}"`);
}
}
console.log(`${paging.total} total, has_more=${paging.has_more}`);
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
const base = "https://app.harbor.my/api/v1"
// searchHit is the subset of hit fields this example prints.
type searchHit struct {
Type string `json:"type"`
Title string `json:"title"`
Filename string `json:"filename"`
NoteTitle string `json:"note_title"`
Score float64 `json:"score"`
HasCoordinates bool `json:"has_coordinates"`
Match string `json:"match"`
}
func main() {
// Search notes and attachments: free text + tag + date-range operators.
params := url.Values{}
params.Set("q", "budget tag:finance -draft created:day-30")
params.Set("types", "note,attachment")
params.Set("limit", "20")
req, err := http.NewRequest("GET", base+"/search?"+params.Encode(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Data []searchHit `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, hit := range out.Data {
if hit.Type == "note" {
fmt.Printf("note: %s (score %.2f)\n", hit.Title, hit.Score)
} else if hit.Match == "name" {
// Found by its file name: no snippet, nothing to highlight.
fmt.Printf("file name: %s in %q\n", hit.Filename, hit.NoteTitle)
} else {
fmt.Printf("attachment: %s in %q\n", hit.Filename, hit.NoteTitle)
}
}
}
Get OCR highlight coordinates
GET /api/v1/search/coordinates · scope: search
Return the bounding boxes of OCR-matched words on a single attachment, grouped
by page, so you can draw highlight overlays on the rendered document or image.
This is the endpoint that powers Harbor’s yellow inline OCR highlights: search
for a word, open a scanned PDF from the results, and the word is marked right
on the page — these boxes drew that. Use it with the text hits
(match: "text") from GET /search that have
has_coordinates: true.
Online vs. offline OCR search. The coordinates endpoint is online-only — per-word boxes never sync. The searchable OCR text does sync (as the pull-only
ocr_textrecord — see the Sync API), so a native client can search inside attachments offline, then call this endpoint online when the user opens a hit and wants the highlight overlay. A recording’s transcript and summary sync the same way, as the pull-onlytranscriptrecord.
| Field | Type | Required | Description |
|---|---|---|---|
resource_id | string (UUID) | Yes | The attachment’s resource id. Missing → 422; unknown, another user’s, or tombstoned → 404. |
q | string | One of q/terms | A query string (full grammar). Its positive free-text and phrase terms become the highlight targets; operators (tag:, notebook:, dates) are ignored here. |
terms | string (CSV) | One of q/terms | Literal terms to highlight, e.g. terms=budget,q3. Takes precedence over q. |
page | integer | No | Restrict to a single 0-based page index (non-negative; invalid → 422). |
max_boxes | integer | No | Default 1000, hard cap 2000 (clamped). Total word boxes + phrase-span words; when the cap clips output, truncated is true. |
At least one of q / terms must be supplied and yield a usable term, or the
request is a 422.
curl -G "https://app.harbor.my/api/v1/search/coordinates" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode 'resource_id=0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40' \
--data-urlencode 'q=budget "q3 plan"'
The response is a single { data } resource with one entry per page that had
at least one hit:
{
"data": {
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"mime": "application/pdf",
"page_count": 3,
"terms": ["budget", "q3 plan"],
"pages": [
{
"page": 0,
"page_width": 1700,
"page_height": 2200,
"matches": [
{
"term": "budget",
"word": "Budget",
"word_index": 42,
"box": { "x": 510.0, "y": 880.0, "w": 120.0, "h": 28.0 },
"norm": { "x": 0.3, "y": 0.4, "w": 0.0706, "h": 0.0127 },
"confidence": 0.98
}
],
"phrases": [
{
"phrase": "q3 plan",
"word_indexes": [43, 44],
"boxes": [
{ "x": 640.0, "y": 880.0, "w": 70.0, "h": 28.0 },
{ "x": 716.0, "y": 880.0, "w": 90.0, "h": 28.0 }
],
"norm_union": { "x": 0.376, "y": 0.4, "w": 0.0976, "h": 0.0127 }
}
]
}
],
"truncated": false
}
}
Top-level fields:
| Field | Type | Description |
|---|---|---|
resource_id | string | The attachment you asked about. |
mime | string | The attachment’s MIME type. |
page_count | integer | The attachment’s OCR page count. 0 when no OCR result row exists. |
terms | array | The resolved highlight terms, after folding. |
pages | array | Only pages that produced hits, ordered ascending. |
truncated | boolean | true when the max_boxes cap clipped the output — more matches exist than were returned. |
Each page carries page (0-based index), page_width / page_height in
pixels when known (omitted when the OCR result lacked page dimensions), and
two arrays:
| Field | Type | Description |
|---|---|---|
matches[].term | string | The requested term this word matched. |
matches[].word | string | The actual OCR text on the page. |
matches[].word_index | integer | 0-based per-page word index. |
matches[].box | object | Pixel rectangle {x, y, w, h}. Omitted when page pixel dimensions are unknown. |
matches[].norm | object | Normalized rectangle, always present, clamped to [0,1]. Multiply by your rendered page size to place the highlight at any zoom. |
matches[].confidence | number | OCR confidence for the word. |
phrases[].phrase | string | The matched phrase. |
phrases[].word_indexes | array | The contiguous word indexes of the span. |
phrases[].boxes | array | Per-word pixel rectangles (omitted when pixel dimensions are unknown). |
phrases[].norm_union | object | One normalized rectangle covering the whole span — draw a single highlight per phrase. |
Term matching. Terms are folded the same way the index folds words —
lower-cased, NFKC-normalized, diacritics stripped — and matched on the same
sub-tokens the FTS index produces. The unicode61 tokenizer splits on
anything that isn’t a letter, digit, or _, so the single OCR word
(503)884-4426 is matched by a query for 4426, and García, still matches
its bare term. Porter stemming is not replicated here: a stem-only search
match (say insurance → the OCR word Insured) returns the search hit but
draws no box. Negated terms are never highlighted, and a multi-word phrase
matches only a run of consecutive OCR words in order.
Errors
403 encrypted_not_searchable— the resource is encrypted, or any live owning note is encrypted. Fail-closed: nothing is read or returned.409 ocr_not_ready— the resource’socr_statusis notdone, so there are no word boxes yet.404 not_found— no such resource for this user, or it is tombstoned.422 validation_failed—resource_idmissing, neitherqnortermsyields a usable term, a malformedq, or an invalidpage.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
# Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
resp = requests.get(
f"{BASE}/search/coordinates",
headers=HEADERS,
params={
"resource_id": "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
"q": 'budget "q3 plan"',
},
)
if resp.status_code == 409:
print("OCR still running — try again shortly")
raise SystemExit(1)
resp.raise_for_status()
data = resp.json()["data"]
for page in data["pages"]:
for m in page["matches"]:
# `norm` is always present — scale it to your rendered page size.
n = m["norm"]
print(f"page {page['page']}: '{m['word']}' at "
f"x={n['x']:.3f} y={n['y']:.3f} w={n['w']:.3f} h={n['h']:.3f}")
for ph in page["phrases"]:
u = ph["norm_union"]
print(f"page {page['page']}: phrase '{ph['phrase']}' union "
f"x={u['x']:.3f} y={u['y']:.3f}")
if data["truncated"]:
print("output clipped by max_boxes — raise it for more")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
// Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
const params = new URLSearchParams({
resource_id: "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40",
q: 'budget "q3 plan"',
});
const res = await fetch(`${BASE}/search/coordinates?${params}`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (res.status === 409) throw new Error("OCR still running — try again shortly");
if (!res.ok) throw new Error(`coordinates failed: ${res.status}`);
const { data } = await res.json();
for (const page of data.pages) {
for (const m of page.matches) {
// `norm` is always present — scale it to your rendered page size
// to place the yellow highlight at any zoom level.
const { x, y, w, h } = m.norm;
console.log(`page ${page.page}: "${m.word}" at ${x},${y} (${w}×${h})`);
}
for (const ph of page.phrases) {
console.log(`page ${page.page}: phrase "${ph.phrase}"`, ph.norm_union);
}
}
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
const base = "https://app.harbor.my/api/v1"
// rect is a normalized or pixel rectangle from the coordinates payload.
type rect struct {
X, Y, W, H float64
}
// coordPage is one page entry with its word matches and phrase spans.
type coordPage struct {
Page int `json:"page"`
Matches []struct {
Term string `json:"term"`
Word string `json:"word"`
WordIndex int `json:"word_index"`
Norm rect `json:"norm"`
} `json:"matches"`
Phrases []struct {
Phrase string `json:"phrase"`
NormUnion rect `json:"norm_union"`
} `json:"phrases"`
}
func main() {
// Fetch OCR highlight boxes for one attachment (a has_coordinates hit).
params := url.Values{}
params.Set("resource_id", "0f9c2b1e-6a4d-4e8b-9c2f-1b7a3d5e8f40")
params.Set("q", `budget "q3 plan"`)
req, err := http.NewRequest("GET", base+"/search/coordinates?"+params.Encode(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
fmt.Println("OCR still running — try again shortly")
return
}
var out struct {
Data struct {
Pages []coordPage `json:"pages"`
Truncated bool `json:"truncated"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, page := range out.Data.Pages {
for _, m := range page.Matches {
// Norm is always present — scale it to your rendered page size.
fmt.Printf("page %d: %q at %.3f,%.3f\n", page.Page, m.Word, m.Norm.X, m.Norm.Y)
}
for _, ph := range page.Phrases {
fmt.Printf("page %d: phrase %q union at %.3f,%.3f\n",
page.Page, ph.Phrase, ph.NormUnion.X, ph.NormUnion.Y)
}
}
}
Related
- Files API — fetch attachment bytes by
hash, checkocr_status - Sync API — the pull-only
ocr_textrecord for offline attachment search - Search in Harbor — what this API looks like in the product