dsh-book-reader 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -828,8 +828,8 @@ export function printedPageFor(info, pdfPage, reported = null) {
828
828
  * @param options - selection, explicit mode, `getInfo` result, and page cap.
829
829
  * @returns physical pages, the resolved mode, and the offset used.
830
830
  */
831
- export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall }) {
832
- const requested = parsePageSelection(selection, { maxPages })
831
+ export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall, hardLimit = PAGE_SELECTION_HARD_LIMIT }) {
832
+ const requested = parsePageSelection(selection, { maxPages, hardLimit })
833
833
  const detected = detectPageMode(info)
834
834
  const mode = pageNumbers ?? (detected.detectable ? 'printed' : 'pdf')
835
835
  if (mode !== 'printed' && mode !== 'pdf') {
@@ -1199,6 +1199,19 @@ const PRINTED_PAGE_SCHEMA = {
1199
1199
  oneOf: [{ type: 'integer' }, { type: 'string' }, { type: 'null' }],
1200
1200
  }
1201
1201
 
1202
+ /**
1203
+ * DSH's JsonSchema dialect marks a property required with `required: true`; a
1204
+ * property that may be absent simply omits `required`. `required: false` is
1205
+ * rejected by defineTool at registration time.
1206
+ *
1207
+ * `IMAGE_VALUE_SCHEMA` is required where it is used for a present image, so an
1208
+ * optional use must drop that marker instead of overriding it to false.
1209
+ */
1210
+ function optionalPropertySchema(schema) {
1211
+ const { required: _required, ...rest } = schema
1212
+ return rest
1213
+ }
1214
+
1202
1215
  const SECTION_SCHEMA = {
1203
1216
  type: 'object',
1204
1217
  additionalProperties: false,
@@ -1432,13 +1445,12 @@ export function createRouteHandler({
1432
1445
  sendJson(response, target.status, { ok: false, error: target.error })
1433
1446
  return
1434
1447
  }
1435
- const query = (url.searchParams.get('q') ?? '').trim()
1436
- if (query === '') {
1437
- sendJson(response, 400, { ok: false, error: 'missing-query' })
1438
- return
1439
- }
1440
- if (query.length > 1000) {
1441
- sendJson(response, 400, { ok: false, error: 'query-too-long' })
1448
+ let query
1449
+ try {
1450
+ query = cleanPdfSearchQuery(url.searchParams.get('q') ?? '')
1451
+ } catch (error) {
1452
+ const tooLong = error instanceof RangeError
1453
+ sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
1442
1454
  return
1443
1455
  }
1444
1456
  const rawLimit = url.searchParams.get('limit')
@@ -1460,8 +1472,17 @@ export function createRouteHandler({
1460
1472
  printed: hit.printed ?? null,
1461
1473
  snippet: String(hit.snippet ?? ''),
1462
1474
  }))
1463
- sendJson(response, 200, { ok: true, hits })
1475
+ const mode = ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase'
1476
+ sendJson(response, 200, { ok: true, mode, hits })
1464
1477
  } catch (error) {
1478
+ if (
1479
+ error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
1480
+ (error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
1481
+ ) {
1482
+ const tooLong = error?.code === 'PDF_SEARCH_QUERY_TOO_LONG'
1483
+ sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
1484
+ return
1485
+ }
1465
1486
  sendJson(response, 500, {
1466
1487
  ok: false,
1467
1488
  error: 'search-failed',
@@ -1679,6 +1700,52 @@ export function trimPageText(raw, maxChars) {
1679
1700
  return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
1680
1701
  }
1681
1702
 
1703
+ /** Longest search query accepted after wrapper/whitespace cleanup. */
1704
+ const MAX_SEARCH_QUERY_LENGTH = 1000
1705
+
1706
+ /** Whole-query wrappers the model may add around a search query. */
1707
+ const SEARCH_QUERY_WRAPPERS = [
1708
+ ['"', '"'],
1709
+ ["'", "'"],
1710
+ ['“', '”'],
1711
+ ['‘', '’'],
1712
+ ['`', '`'],
1713
+ ['$', '$'],
1714
+ ]
1715
+
1716
+ /**
1717
+ * Tool/route-side search-query cleanup: trim, strip a pair of wrapping
1718
+ * quotes/backticks/`$…$`, collapse whitespace, and reject empty/over-long
1719
+ * input. Mirrors `cleanSearchQuery()` in core/pdf.js; kept local because the
1720
+ * PDF core is imported lazily, only when a PDF operation actually runs.
1721
+ *
1722
+ * @param {unknown} raw
1723
+ * @returns {string}
1724
+ */
1725
+ function cleanPdfSearchQuery(raw) {
1726
+ if (typeof raw !== 'string') throw new TypeError('query must be a non-empty string')
1727
+
1728
+ let query = raw.trim()
1729
+ let stripped = true
1730
+ while (stripped && query.length >= 2) {
1731
+ stripped = false
1732
+ for (const [open, close] of SEARCH_QUERY_WRAPPERS) {
1733
+ if (query.startsWith(open) && query.endsWith(close)) {
1734
+ query = query.slice(open.length, query.length - close.length).trim()
1735
+ stripped = true
1736
+ break
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ query = query.replace(/\s+/gu, ' ').trim()
1742
+ if (query === '') throw new TypeError('query must be a non-empty string')
1743
+ if (query.length > MAX_SEARCH_QUERY_LENGTH) {
1744
+ throw new RangeError(`query is longer than ${MAX_SEARCH_QUERY_LENGTH} characters`)
1745
+ }
1746
+ return query
1747
+ }
1748
+
1682
1749
  /** Register `pdf_info` and `pdf_search`. */
1683
1750
  export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
1684
1751
  return [
@@ -1788,16 +1855,36 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1788
1855
  makeTool({
1789
1856
  name: 'pdf_search',
1790
1857
  description:
1791
- 'Search a PDF\'s text layer; returns page numbers and snippets. Text only, cheap.',
1858
+ 'Search a PDF\'s text layer; returns page numbers, snippets, and the matching mode. Literal-ish search over normalized text: case-insensitive by default, tolerant of wrapping quotes/backticks and `$…$` LaTeX markup, hyphen/space-insensitive fallback, then term-AND fallback when no literal match. Body pages rank before front matter. Pass `pages` to restrict the scan to a page range (printed pages by default), e.g. when reading a later chapter. Text only, cheap.',
1792
1859
  parameters: {
1793
1860
  path: {
1794
1861
  type: 'string',
1795
1862
  required: true,
1796
1863
  description: 'Absolute .pdf path (relative resolves against the session directory).',
1797
1864
  },
1798
- query: { type: 'string', required: true, description: 'Literal text to search for.' },
1865
+ query: {
1866
+ type: 'string',
1867
+ required: true,
1868
+ description:
1869
+ 'Text or formula to find. Wrapping quotes/backticks/$…$ are stripped; use a distinctive phrase.',
1870
+ },
1871
+ pages: {
1872
+ description:
1873
+ 'Optional 1-based pages to search within: "132-140", "132,135", 132, or [132,133]. Interpreted as printed numbers when the PDF has an offset, unless page_numbers is "pdf". Use it to scope a search to the chapter the user is reading.',
1874
+ oneOf: [
1875
+ { type: 'integer' },
1876
+ { type: 'string' },
1877
+ { type: 'array', items: { oneOf: [{ type: 'integer' }, { type: 'string' }] } },
1878
+ ],
1879
+ },
1880
+ page_numbers: {
1881
+ type: 'string',
1882
+ enum: ['printed', 'pdf'],
1883
+ description:
1884
+ 'How to interpret `pages`. Defaults to "printed" when the PDF exposes numeric printed-page labels, otherwise "pdf".',
1885
+ },
1799
1886
  ignore_case: { type: 'boolean', description: 'Case-insensitive search. Defaults to true.' },
1800
- limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 20; capped by plugin config.' },
1887
+ limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 30; capped by plugin config.' },
1801
1888
  },
1802
1889
  output: {
1803
1890
  schema: {
@@ -1811,6 +1898,13 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1811
1898
  required: true,
1812
1899
  description: 'Number of pages with at least one hit; may exceed the returned hit count when limit truncates.',
1813
1900
  },
1901
+ mode: {
1902
+ type: 'string',
1903
+ enum: ['phrase', 'compact', 'terms'],
1904
+ required: true,
1905
+ description:
1906
+ 'Which pass produced the hits: literal phrase, space-insensitive, or all-terms.',
1907
+ },
1814
1908
  hits: {
1815
1909
  type: 'array',
1816
1910
  required: true,
@@ -1834,13 +1928,51 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1834
1928
  async execute(args, exec) {
1835
1929
  const path = resolveToolPath(args.path, exec)
1836
1930
  assertInsideWorkspace(path, exec, config)
1837
- const query = typeof args.query === 'string' ? args.query.trim() : ''
1838
- if (query === '') throw codedError('query must be a non-empty string', 'PDF_READER_INVALID_QUERY')
1839
- if (query.length > 1000) throw codedError('query is longer than 1000 characters', 'PDF_READER_INVALID_QUERY')
1931
+ let query
1932
+ try {
1933
+ query = cleanPdfSearchQuery(args.query)
1934
+ } catch (error) {
1935
+ throw codedError(
1936
+ error instanceof Error ? error.message : String(error),
1937
+ 'PDF_READER_INVALID_QUERY',
1938
+ )
1939
+ }
1840
1940
  await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
1841
- const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 20, config.maxSearchResults))
1941
+ const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 30, config.maxSearchResults))
1842
1942
  const ignoreCase = args.ignore_case !== false
1843
- const result = await core.searchPdf(path, query, { ignoreCase, limit })
1943
+ let pdfPages
1944
+ if (args.pages !== undefined) {
1945
+ const info = (await infoCache.load(path)).info
1946
+ const pageCount = Number.isInteger(info?.pageCount) && info.pageCount > 0
1947
+ ? info.pageCount
1948
+ : PAGE_SELECTION_HARD_LIMIT
1949
+ const resolved = resolveRequestedPages({
1950
+ selection: args.pages,
1951
+ pageNumbers: args.page_numbers,
1952
+ info,
1953
+ maxPages: pageCount,
1954
+ hardLimit: Math.max(PAGE_SELECTION_HARD_LIMIT, pageCount),
1955
+ })
1956
+ pdfPages = resolved.pdfPages
1957
+ }
1958
+ let result
1959
+ try {
1960
+ result = await core.searchPdf(path, query, {
1961
+ ignoreCase,
1962
+ limit,
1963
+ ...(pdfPages === undefined ? {} : { pdfPages }),
1964
+ })
1965
+ } catch (error) {
1966
+ // Cleanup above already rejects ordinary empties; core also rejects a
1967
+ // query that normalization erases entirely (e.g. "---").
1968
+ if (
1969
+ error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
1970
+ (error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
1971
+ ) {
1972
+ throw codedError(error.message, 'PDF_READER_INVALID_QUERY')
1973
+ }
1974
+ throw error
1975
+ }
1844
1976
  const rawHits = Array.isArray(result?.hits) ? result.hits : []
1845
1977
  const hits = rawHits.slice(0, limit).map((hit) => ({
1846
1978
  pdf: Number(hit.pdf),
@@ -1852,6 +1984,7 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1852
1984
  path,
1853
1985
  query,
1854
1986
  pages: Number.isInteger(result?.pages) ? result.pages : new Set(hits.map((hit) => hit.pdf)).size,
1987
+ mode: ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase',
1855
1988
  hits,
1856
1989
  }
1857
1990
  },
@@ -1952,7 +2085,7 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
1952
2085
  pdf: { type: 'integer', required: true },
1953
2086
  printed: { oneOf: [{ type: 'integer' }, { type: 'string' }], required: true },
1954
2087
  tokens: { type: 'integer' },
1955
- image: { ...IMAGE_VALUE_SCHEMA, required: false },
2088
+ image: optionalPropertySchema(IMAGE_VALUE_SCHEMA),
1956
2089
  text: { type: 'string' },
1957
2090
  textTruncated: { type: 'boolean' },
1958
2091
  textUnavailable: { type: 'boolean' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-book-reader",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Read books and papers in DSH: a reading toolbar on the official PDF preview that remembers your page and zoom, plus PDF tools for the model (page map, full-text search, page text and images).",
5
5
  "keywords": [
6
6
  "dsh",