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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,7 @@ The rules:
## License

MIT

### Geographic filtering and scanner context

Use `?lat=41.88&long=-87.62&radius=10` across located collections, searches and feeds. [Geographic queries](docs/geographic-queries.md) documents the API, CLI/MCP flags, permissioned scanner catalogs, coverage semantics and migration.
16 changes: 14 additions & 2 deletions apps/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { join } from 'node:path';
import { createInterface } from 'node:readline';

export const VERSION = '0.26.0';
const GEO_FLAGS = ['lat', 'long', 'radius', 'unit', 'bbox', 'sort', 'offset'];
const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev';
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
Expand Down Expand Up @@ -94,7 +95,7 @@ export const COMMANDS = [
{
name: 'recent',
usage:
'recent [--collection …] [--source …] [--kind …] [--tags a,b] [--from …] [--to …] [--since …] [--sort id|published|updated]',
'recent [--collection …] [--source …] [--kind …] [--tags a,b] [--from …] [--to …] [--since …] [--sort id|published|updated|distance]',
summary:
'Newest items across a collection or source, or a window of them, or what changed since.',
options: [
Expand All @@ -104,8 +105,11 @@ export const COMMANDS = [
'--tags a,b (every one must be on the item)',
'--from / --to (ISO, on published_at)',
'--since (ISO, on updated_at)',
'--sort id|published|updated',
'--sort id|published|updated|distance',
'--order asc|desc',
'--lat / --long / --radius / --unit km|mi',
'--bbox west,south,east,north',
'--offset (distance pagination)',
'--limit',
'--json',
'--urls',
Expand Down Expand Up @@ -597,6 +601,9 @@ export async function run(
tags: csv(flags.tags),
q: flags.q,
upcoming: Boolean(flags.upcoming),
...Object.fromEntries(
GEO_FLAGS.filter((k) => flags[k] !== undefined).map((k) => [k, flags[k]]),
),
public: !flags.private,
});
out(
Expand All @@ -618,6 +625,7 @@ export async function run(
const [slug] = rest;
if (!slug) throw new Error('items <feed>');
const qs = new URLSearchParams();
for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]);
if (flags.limit) qs.set('limit', flags.limit);
if (flags.before) qs.set('before', flags.before);
const { items } = await client.get(`/api/v1/feeds/${slug}/items?${qs}`);
Expand All @@ -626,6 +634,7 @@ export async function run(
}
case 'recent': {
const qs = new URLSearchParams();
for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]);
for (const k of [
'collection',
'source',
Expand All @@ -649,6 +658,7 @@ export async function run(
const name = rest.join(' ');
if (!name) throw new Error('match <name>');
const qs = new URLSearchParams({ q: name });
for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]);
for (const k of ['collection', 'kind', 'year', 'date', 'tags', 'limit'])
if (flags[k]) qs.set(k, flags[k]);
const answer = await client.get(`/api/v1/match?${qs}`);
Expand All @@ -668,6 +678,7 @@ export async function run(
}
case 'upcoming': {
const qs = new URLSearchParams();
for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]);
for (const k of ['collection', 'days', 'limit']) if (flags[k]) qs.set(k, flags[k]);
const { items } = await client.get(`/api/v1/items/upcoming?${qs}`);
printItems(items, { json, urls: flags.urls });
Expand All @@ -677,6 +688,7 @@ export async function run(
const term = rest.join(' ');
if (!term) throw new Error('search <query>');
const qs = new URLSearchParams({ q: term });
for (const k of GEO_FLAGS) if (flags[k] !== undefined) qs.set(k, flags[k]);
for (const k of ['collection', 'kind', 'limit']) if (flags[k]) qs.set(k, flags[k]);
const { items } = await client.get(`/api/v1/search?${qs}`);
printItems(items, { json, urls: flags.urls });
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ app.use('*', async (c, next) => {

app.onError((err, c) => {
if (err.redirect) return c.redirect(err.redirect, 303);
if (err.status === 400) {
if (wantsJson(c)) return c.json({ error: err.message }, 400);
return c.text(err.message, 400);
}
if (err instanceof Denied) {
if (wantsJson(c)) return c.json({ error: err.message }, err.status);
const back = new URL(c.req.header('referer') ?? '/', config.siteUrl);
Expand Down
31 changes: 24 additions & 7 deletions apps/web/src/lib/mcp/tools.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { config } from '@nichedb/config';
import { describeAdapters, describeEnrichers } from '@nichedb/core';
import { geoQueryFields, geoSchema } from '@nichedb/core/geo';
import { cleanChannelName, parseName } from '@nichedb/core/names';
import * as profiles from '@nichedb/db/profiles';
import * as q from '@nichedb/db/queries';
Expand Down Expand Up @@ -86,16 +87,20 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
feed: str('Feed slug'),
limit: int('Default 30, max 200'),
before_id: int('Keyset cursor'),
before_id: int('Keyset cursor; cannot combine with distance sorting'),
offset: int('Distance pagination offset'),
},
required: ['feed'],
},
run: async ({ feed, limit, before_id }) => {
run: async ({ feed, limit, before_id, offset, ...location }) => {
const f = await q.getFeed(String(feed));
if (!f) throw toolError(`No feed named ${feed}`);
const items = await q.feedItems(f, {
...geoQueryFields(location),
offset,
limit: Math.min(Number(limit) || 30, 200),
beforeId: before_id ?? null,
});
Expand All @@ -111,19 +116,23 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
collection: str('Collection slug'),
source: str('Source slug'),
kind: str('Item kind'),
limit: int('Default 30, max 200'),
before_id: int('Keyset cursor'),
before_id: int('Keyset cursor; cannot combine with distance sorting'),
offset: int('Distance pagination offset'),
},
},
run: async ({ collection, source, kind, limit, before_id }) => {
run: async ({ collection, source, kind, limit, before_id, offset, ...location }) => {
const col = collection ? await q.getCollection(collection) : null;
if (collection && !col) throw toolError(`No collection named ${collection}`);
const src = source ? await q.getSource(source) : null;
if (source && !src) throw toolError(`No source named ${source}`);
const items = await q.recentItems({
offset,
...geoQueryFields(location),
collectionId: col?.id ?? null,
sourceId: src?.id ?? null,
kind: kind ?? null,
Expand All @@ -139,15 +148,17 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
collection: str('Collection slug'),
days: int('Horizon, default 30'),
limit: int('Default 50'),
},
},
run: async ({ collection, days, limit }) => {
run: async ({ collection, days, limit, ...location }) => {
const col = collection ? await q.getCollection(collection) : null;
if (collection && !col) throw toolError(`No collection named ${collection}`);
const items = await q.upcomingItems({
...geoQueryFields(location),
collectionId: col?.id ?? null,
days: Number(days) || 30,
limit: Math.min(Number(limit) || 50, 200),
Expand All @@ -162,17 +173,19 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
q: str('Query'),
collection: str('Collection slug'),
kind: str('Item kind'),
limit: int('Default 20, max 100'),
},
required: ['q'],
},
run: async ({ q: term, collection, kind, limit }) => {
run: async ({ q: term, collection, kind, limit, ...location }) => {
const col = collection ? await q.getCollection(collection) : null;
if (collection && !col) throw toolError(`No collection named ${collection}`);
const items = await q.searchItems(String(term), {
...geoQueryFields(location),
collectionId: col?.id ?? null,
kind: kind ?? null,
limit: Math.min(Number(limit) || 20, 100),
Expand All @@ -187,6 +200,7 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
q: str('The name as written'),
collection: str('Collection slug: screen, channels or sports'),
kind: str('Item kind: title, channel, fixture'),
Expand All @@ -196,12 +210,13 @@ export const TOOLS = [
},
required: ['q'],
},
run: async ({ q: term, collection, kind, year, date, limit }) => {
run: async ({ q: term, collection, kind, year, date, limit, ...location }) => {
const col = collection ? await q.getCollection(collection) : null;
if (collection && !col) throw toolError(`No collection named ${collection}`);
const parsed = parseName(String(term));
const sports = !col || col.slug === 'sports' || kind === 'fixture';
const items = await q.matchItems(parsed.name, {
...geoQueryFields(location),
collectionId: col?.id ?? null,
kind: kind ?? null,
year: Number(year) || parsed.year || null,
Expand Down Expand Up @@ -234,6 +249,7 @@ export const TOOLS = [
inputSchema: {
type: 'object',
properties: {
...geoSchema,
collection: str('Collection slug'),
name: str('Feed name'),
description: str('Optional'),
Expand All @@ -254,6 +270,7 @@ export const TOOLS = [
name: args.name,
description: args.description,
query: {
...geoQueryFields(args),
sources: args.sources,
kinds: args.kinds,
tags: args.tags,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/scanner-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { GeoQueryError, geoQueryFields } from '@nichedb/core/geo';

export function scannerContextOptions(raw = {}) {
const date = (key) => {
const value = raw[key];
if (value === undefined || value === null || value === '') return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) throw new GeoQueryError(`${key} must be an ISO date`);
return d.toISOString();
};
const from = date('from');
const to = date('to');
if (from && to && from >= to) throw new GeoQueryError('from must precede to');
return { ...geoQueryFields(raw), from, to };
}
export const SCANNER_CONTEXT_NOTE =
'Reported incidents in the coverage area; not verified links to scanner transmissions. An empty result does not imply no crime.';
3 changes: 3 additions & 0 deletions apps/web/src/lib/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export function itemOut(i, siteUrl, { enrichers = null } = {}) {
precision: i.precision,
tags: i.tags,
data: i.data,
...(i.distance_m !== null && i.distance_m !== undefined
? { distance_m: Number(i.distance_m) }
: {}),
first_seen_at: i.first_seen_at,
enrichment: enrichmentOut(
i,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { config } from '@nichedb/config';
import { adapterByName, slugify } from '@nichedb/core';
import { geoQueryFields } from '@nichedb/core/geo';
import * as knowledge from '@nichedb/db/knowledge';
import * as premiumDb from '@nichedb/db/premium';
import * as q from '@nichedb/db/queries';
Expand Down Expand Up @@ -169,7 +170,7 @@ export function normaliseQuery(raw = {}) {
.map((s) => String(s).trim())
.filter(Boolean)
.slice(0, 20);
const out = {};
const out = { ...geoQueryFields(raw) };
const sources = arr(raw.sources);
const kinds = arr(raw.kinds);
const tags = arr(raw.tags).map((t) => t.toLowerCase());
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/routes/api.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { config } from '@nichedb/config';
import { describeAdapters, describeEnrichers } from '@nichedb/core';
import { geoQueryFields } from '@nichedb/core/geo';
import { cleanChannelName, parseName } from '@nichedb/core/names';
import * as q from '@nichedb/db/queries';
import { enqueueRun } from '@nichedb/queue';
Expand All @@ -8,6 +9,7 @@ import { mintPass } from '@profullstack/x402-gateway';
import { callerAddress } from '../lib/auth-throttle.js';
import { isProUser, render, requireUser } from '../lib/http.js';
import { apiLimitFor, planOf } from '../lib/premium.js';
import { SCANNER_CONTEXT_NOTE, scannerContextOptions } from '../lib/scanner-context.js';
import { allowedEnrichers, collectionOut, feedOut, itemOut, sourceOut } from '../lib/serialize.js';
import {
addSource,
Expand Down Expand Up @@ -232,8 +234,10 @@ export function registerApi(app) {
if (!f || (!f.public && f.owner_id !== c.get('user')?.id))
return c.json({ error: 'not found' }, 404);
const items = await q.feedItems(f, {
...geoQueryFields(c.req.query()),
limit: lim(c.req.query('limit'), 50, 200),
beforeId: Number(c.req.query('before')) || null,
offset: Math.max(0, Math.floor(Number(c.req.query('offset')) || 0)),
});
c.header('cache-control', 'public, max-age=60');
return c.json({
Expand All @@ -249,7 +253,7 @@ export function registerApi(app) {
collection: body.collection,
name: body.name,
description: body.description,
query: body,
query: body.query ?? body,
isPublic: body.public,
});
return c.json({ feed: feedOut(await q.getFeed(feed.slug), site()) }, 201);
Expand All @@ -268,7 +272,8 @@ export function registerApi(app) {
body.kinds ||
body.tags ||
body.q !== undefined ||
body.upcoming !== undefined
body.upcoming !== undefined ||
['lat', 'long', 'radius', 'unit', 'bbox', 'sort'].some((key) => body[key] !== undefined)
? body
: undefined),
isPublic: body.public,
Expand Down Expand Up @@ -323,10 +328,11 @@ export function registerApi(app) {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};
const sort = ['id', 'published', 'updated'].includes(c.req.query('sort'))
const sort = ['id', 'published', 'updated', 'distance'].includes(c.req.query('sort'))
? c.req.query('sort')
: 'id';
const items = await q.recentItems({
...geoQueryFields(c.req.query()),
collectionId: col?.id ?? null,
sourceId: src?.id ?? null,
kind: c.req.query('kind') ?? null,
Expand All @@ -340,6 +346,7 @@ export function registerApi(app) {
order: c.req.query('order') === 'asc' ? 'asc' : 'desc',
limit: lim(c.req.query('limit'), 50, 200),
beforeId: Number(c.req.query('before')) || null,
offset: Math.max(0, Math.floor(Number(c.req.query('offset')) || 0)),
afterId: Number(c.req.query('after')) || null,
});
// A mirror asking "what changed since" must not be handed a stale page.
Expand All @@ -361,6 +368,7 @@ export function registerApi(app) {
const date = /^\d{4}-\d{2}-\d{2}$/.test(c.req.query('date') ?? '') ? c.req.query('date') : null;
const sports = col === null || col.slug === 'sports' || kind === 'fixture';
const items = await q.matchItems(parsed.name, {
...geoQueryFields(c.req.query()),
collectionId: col?.id ?? null,
kind,
tags: (c.req.query('tags') ?? '').split(',').filter(Boolean),
Expand All @@ -384,13 +392,33 @@ export function registerApi(app) {
app.get('/api/v1/items/upcoming', async (c) => {
const col = await collectionOrNull(c.req.query('collection'));
const items = await q.upcomingItems({
...geoQueryFields(c.req.query()),
collectionId: col?.id ?? null,
days: lim(c.req.query('days'), 30, 365),
limit: lim(c.req.query('limit'), 100, 200),
});
c.header('cache-control', 'public, max-age=300');
return c.json({ count: items.length, items: items.map((i) => itemOut(i, site())) });
});
app.get('/api/v1/items/:id/nearby-crime', async (c) => {
const scanner = await q.getItem(Number(c.req.param('id')));
if (scanner?.kind !== 'scanner-stream') return c.json({ error: 'scanner not found' }, 404);
const options = scannerContextOptions(c.req.query());
const items = await q.nearbyCrime(scanner, {
...options,
limit: lim(c.req.query('limit'), 20, 100),
});
return c.json({
scanner_id: Number(scanner.id),
relationship: 'within-coverage',
note: SCANNER_CONTEXT_NOTE,
coverage_basis: scanner.data?.coverage_basis ?? 'unknown',
from: options.from,
to: options.to,
count: items.length,
items: items.map((i) => itemOut(i, site())),
});
});
app.get('/api/v1/items/:id', async (c) => {
const item = await q.getItem(Number(c.req.param('id')));
if (!item) return c.json({ error: 'not found' }, 404);
Expand All @@ -402,6 +430,7 @@ export function registerApi(app) {
if (!term) return c.json({ error: 'q is required' }, 400);
const col = await collectionOrNull(c.req.query('collection'));
const items = await q.searchItems(term, {
...geoQueryFields(c.req.query()),
collectionId: col?.id ?? null,
kind: c.req.query('kind') ?? null,
limit: lim(c.req.query('limit'), 30, 100),
Expand Down
Loading
Loading