birdclaw 0.6.0 → 0.7.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/CHANGELOG.md +48 -0
- package/README.md +25 -0
- package/package.json +6 -1
- package/src/cli.ts +438 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +377 -13
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +11 -1
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +22 -8
- package/src/styles.css +22 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { getAuthenticatedBirdAccountEffect } from "./bird";
|
|
3
|
+
import { getNativeDb } from "./db";
|
|
4
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
5
|
+
import type {
|
|
6
|
+
LiveDataSourceAccount,
|
|
7
|
+
LiveDataSourceCapability,
|
|
8
|
+
LiveDataSourcesResponse,
|
|
9
|
+
LiveDataSourceStatus,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import {
|
|
12
|
+
getTransportStatusEffect,
|
|
13
|
+
lookupAuthenticatedOAuth2UserEffect,
|
|
14
|
+
readXurlOAuth2AccountsEffect,
|
|
15
|
+
} from "./xurl";
|
|
16
|
+
|
|
17
|
+
function errorMessage(error: unknown) {
|
|
18
|
+
return error instanceof Error ? error.message : String(error);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readLocalAccounts(): LiveDataSourceAccount[] {
|
|
22
|
+
const db = getNativeDb();
|
|
23
|
+
const rows = db
|
|
24
|
+
.prepare(
|
|
25
|
+
`
|
|
26
|
+
select id, handle, external_user_id, is_default
|
|
27
|
+
from accounts
|
|
28
|
+
order by is_default desc, lower(handle) asc
|
|
29
|
+
`,
|
|
30
|
+
)
|
|
31
|
+
.all() as Array<{
|
|
32
|
+
id: string;
|
|
33
|
+
handle: string;
|
|
34
|
+
external_user_id: string | null;
|
|
35
|
+
is_default: number;
|
|
36
|
+
}>;
|
|
37
|
+
return rows.map((row) => ({
|
|
38
|
+
id: row.external_user_id ?? row.id,
|
|
39
|
+
handle: row.handle,
|
|
40
|
+
username: row.handle.replace(/^@/, ""),
|
|
41
|
+
isDefault: row.is_default === 1,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getBirdclawStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
|
|
46
|
+
return Effect.try({
|
|
47
|
+
try: () => readLocalAccounts(),
|
|
48
|
+
catch: (error) => error,
|
|
49
|
+
}).pipe(
|
|
50
|
+
Effect.map((accounts) => ({
|
|
51
|
+
source: "birdclaw" as const,
|
|
52
|
+
label: "Birdclaw local",
|
|
53
|
+
works: true,
|
|
54
|
+
installed: true,
|
|
55
|
+
status: "ok" as const,
|
|
56
|
+
detail:
|
|
57
|
+
accounts.length > 0
|
|
58
|
+
? `${accounts.length.toString()} local account${accounts.length === 1 ? "" : "s"}`
|
|
59
|
+
: "local database ready; no accounts imported yet",
|
|
60
|
+
accounts,
|
|
61
|
+
})),
|
|
62
|
+
Effect.catchAll((error) =>
|
|
63
|
+
Effect.succeed({
|
|
64
|
+
source: "birdclaw" as const,
|
|
65
|
+
label: "Birdclaw local",
|
|
66
|
+
works: false,
|
|
67
|
+
installed: true,
|
|
68
|
+
status: "error" as const,
|
|
69
|
+
detail: errorMessage(error),
|
|
70
|
+
accounts: [],
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getBirdStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
|
|
77
|
+
return getAuthenticatedBirdAccountEffect().pipe(
|
|
78
|
+
Effect.map((account) => ({
|
|
79
|
+
source: "bird" as const,
|
|
80
|
+
label: "bird",
|
|
81
|
+
works: true,
|
|
82
|
+
installed: true,
|
|
83
|
+
status: "ok" as const,
|
|
84
|
+
detail: `authenticated as @${account.username}`,
|
|
85
|
+
accounts: [
|
|
86
|
+
{
|
|
87
|
+
...(account.id ? { id: account.id } : {}),
|
|
88
|
+
username: account.username,
|
|
89
|
+
handle: `@${account.username}`,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
})),
|
|
93
|
+
Effect.catchAll((error) =>
|
|
94
|
+
Effect.succeed({
|
|
95
|
+
source: "bird" as const,
|
|
96
|
+
label: "bird",
|
|
97
|
+
works: false,
|
|
98
|
+
status: "error" as const,
|
|
99
|
+
detail: errorMessage(error),
|
|
100
|
+
accounts: [],
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getXurlStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
|
|
107
|
+
return Effect.gen(function* () {
|
|
108
|
+
const transport = yield* getTransportStatusEffect();
|
|
109
|
+
const oauth2Accounts = yield* readXurlOAuth2AccountsEffect();
|
|
110
|
+
const authenticated = yield* lookupAuthenticatedOAuth2UserEffect().pipe(
|
|
111
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
112
|
+
);
|
|
113
|
+
const authenticatedAccount =
|
|
114
|
+
authenticated && typeof authenticated === "object"
|
|
115
|
+
? ({
|
|
116
|
+
...(typeof authenticated.id === "string"
|
|
117
|
+
? { id: authenticated.id }
|
|
118
|
+
: {}),
|
|
119
|
+
...(typeof authenticated.username === "string"
|
|
120
|
+
? {
|
|
121
|
+
username: authenticated.username,
|
|
122
|
+
handle: `@${authenticated.username}`,
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
} satisfies LiveDataSourceAccount)
|
|
126
|
+
: undefined;
|
|
127
|
+
const accounts: LiveDataSourceAccount[] = [
|
|
128
|
+
...(authenticatedAccount ? [authenticatedAccount] : []),
|
|
129
|
+
...oauth2Accounts,
|
|
130
|
+
];
|
|
131
|
+
const deduped = accounts.filter(
|
|
132
|
+
(account, index) =>
|
|
133
|
+
accounts.findIndex(
|
|
134
|
+
(candidate) =>
|
|
135
|
+
(candidate.app ?? "") === (account.app ?? "") &&
|
|
136
|
+
(candidate.username ?? candidate.handle ?? "") ===
|
|
137
|
+
(account.username ?? account.handle ?? ""),
|
|
138
|
+
) === index,
|
|
139
|
+
);
|
|
140
|
+
const works = transport.availableTransport === "xurl";
|
|
141
|
+
return {
|
|
142
|
+
source: "xurl" as const,
|
|
143
|
+
label: "xurl",
|
|
144
|
+
works,
|
|
145
|
+
installed: transport.installed,
|
|
146
|
+
status: works ? ("ok" as const) : ("warning" as const),
|
|
147
|
+
detail: transport.statusText,
|
|
148
|
+
accounts: deduped,
|
|
149
|
+
};
|
|
150
|
+
}).pipe(
|
|
151
|
+
Effect.catchAll((error) =>
|
|
152
|
+
Effect.succeed({
|
|
153
|
+
source: "xurl" as const,
|
|
154
|
+
label: "xurl",
|
|
155
|
+
works: false,
|
|
156
|
+
status: "error" as const,
|
|
157
|
+
detail: errorMessage(error),
|
|
158
|
+
accounts: [],
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const capabilities: LiveDataSourceCapability[] = [
|
|
165
|
+
{
|
|
166
|
+
key: "timeline",
|
|
167
|
+
label: "Home timeline",
|
|
168
|
+
primary: "xurl",
|
|
169
|
+
fallbacks: ["bird"],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: "mentions",
|
|
173
|
+
label: "Mentions",
|
|
174
|
+
primary: "xurl",
|
|
175
|
+
fallbacks: ["bird", "birdclaw"],
|
|
176
|
+
notes: "bird fallback is skipped when a since/start cursor requires xurl.",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
key: "search",
|
|
180
|
+
label: "Fresh search",
|
|
181
|
+
primary: "bird",
|
|
182
|
+
fallbacks: ["xurl", "birdclaw"],
|
|
183
|
+
notes: "dated searches require xurl.",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
key: "dms",
|
|
187
|
+
label: "DMs",
|
|
188
|
+
primary: "xurl",
|
|
189
|
+
fallbacks: ["bird", "birdclaw"],
|
|
190
|
+
notes: "message requests require bird.",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
key: "follow-graph",
|
|
194
|
+
label: "Followers / following",
|
|
195
|
+
primary: "bird",
|
|
196
|
+
fallbacks: ["xurl", "birdclaw"],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
export function getLiveDataSourcesEffect(): Effect.Effect<
|
|
201
|
+
LiveDataSourcesResponse,
|
|
202
|
+
never
|
|
203
|
+
> {
|
|
204
|
+
return Effect.gen(function* () {
|
|
205
|
+
const sources = yield* Effect.all(
|
|
206
|
+
[getBirdclawStatusEffect(), getBirdStatusEffect(), getXurlStatusEffect()],
|
|
207
|
+
{ concurrency: "unbounded" },
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
generatedAt: new Date().toISOString(),
|
|
211
|
+
sources,
|
|
212
|
+
capabilities,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getLiveDataSources(): Promise<LiveDataSourcesResponse> {
|
|
218
|
+
return runEffectPromise(getLiveDataSourcesEffect());
|
|
219
|
+
}
|
package/src/lib/db.ts
CHANGED
|
@@ -117,7 +117,13 @@ export interface TweetCollectionsTable {
|
|
|
117
117
|
export interface TweetAccountEdgesTable {
|
|
118
118
|
account_id: string;
|
|
119
119
|
tweet_id: string;
|
|
120
|
-
kind:
|
|
120
|
+
kind:
|
|
121
|
+
| "home"
|
|
122
|
+
| "mention"
|
|
123
|
+
| "authored"
|
|
124
|
+
| "search"
|
|
125
|
+
| "profile"
|
|
126
|
+
| "thread_context";
|
|
121
127
|
first_seen_at: string;
|
|
122
128
|
last_seen_at: string;
|
|
123
129
|
seen_count: number;
|
|
@@ -258,6 +264,31 @@ export interface FollowEventsTable {
|
|
|
258
264
|
snapshot_id: string;
|
|
259
265
|
}
|
|
260
266
|
|
|
267
|
+
export interface GeocodedLocationsTable {
|
|
268
|
+
normalized_key: string;
|
|
269
|
+
original: string;
|
|
270
|
+
lat: number;
|
|
271
|
+
lng: number;
|
|
272
|
+
formatted: string | null;
|
|
273
|
+
country_code: string | null;
|
|
274
|
+
confidence: number | null;
|
|
275
|
+
provider: string;
|
|
276
|
+
approx_radius_m: number | null;
|
|
277
|
+
bounds_json: string;
|
|
278
|
+
components_json: string;
|
|
279
|
+
hits: number;
|
|
280
|
+
created_at: string;
|
|
281
|
+
last_used_at: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface GeocodedLocationsUnresolvedTable {
|
|
285
|
+
normalized_key: string;
|
|
286
|
+
original: string;
|
|
287
|
+
reason: string;
|
|
288
|
+
last_attempted_at: string;
|
|
289
|
+
ttl_until: string | null;
|
|
290
|
+
}
|
|
291
|
+
|
|
261
292
|
export interface BirdclawDatabase {
|
|
262
293
|
accounts: AccountsTable;
|
|
263
294
|
profiles: ProfilesTable;
|
|
@@ -281,6 +312,8 @@ export interface BirdclawDatabase {
|
|
|
281
312
|
follow_snapshots: FollowSnapshotsTable;
|
|
282
313
|
follow_snapshot_members: FollowSnapshotMembersTable;
|
|
283
314
|
follow_events: FollowEventsTable;
|
|
315
|
+
geocoded_locations: GeocodedLocationsTable;
|
|
316
|
+
geocoded_locations_unresolved: GeocodedLocationsUnresolvedTable;
|
|
284
317
|
}
|
|
285
318
|
|
|
286
319
|
let nativeDb: Database | undefined;
|
|
@@ -563,6 +596,31 @@ const BASE_SCHEMA_SQL = `
|
|
|
563
596
|
snapshot_id text not null
|
|
564
597
|
);
|
|
565
598
|
|
|
599
|
+
create table if not exists geocoded_locations (
|
|
600
|
+
normalized_key text primary key,
|
|
601
|
+
original text not null,
|
|
602
|
+
lat real not null,
|
|
603
|
+
lng real not null,
|
|
604
|
+
formatted text,
|
|
605
|
+
country_code text,
|
|
606
|
+
confidence integer,
|
|
607
|
+
provider text not null,
|
|
608
|
+
approx_radius_m real,
|
|
609
|
+
bounds_json text not null default '{}',
|
|
610
|
+
components_json text not null default '{}',
|
|
611
|
+
hits integer not null default 1,
|
|
612
|
+
created_at text not null,
|
|
613
|
+
last_used_at text not null
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
create table if not exists geocoded_locations_unresolved (
|
|
617
|
+
normalized_key text primary key,
|
|
618
|
+
original text not null,
|
|
619
|
+
reason text not null,
|
|
620
|
+
last_attempted_at text not null,
|
|
621
|
+
ttl_until text
|
|
622
|
+
);
|
|
623
|
+
|
|
566
624
|
create virtual table if not exists tweets_fts using fts5(
|
|
567
625
|
tweet_id unindexed,
|
|
568
626
|
text
|
|
@@ -611,6 +669,9 @@ const INDEX_SQL = `
|
|
|
611
669
|
create index if not exists idx_follow_edges_profile on follow_edges(profile_id, current);
|
|
612
670
|
create index if not exists idx_follow_snapshots_account on follow_snapshots(account_id, direction, completed_at desc);
|
|
613
671
|
create index if not exists idx_follow_events_account on follow_events(account_id, direction, kind, event_at desc);
|
|
672
|
+
create index if not exists idx_geocoded_locations_country on geocoded_locations(country_code);
|
|
673
|
+
create index if not exists idx_geocoded_locations_last_used on geocoded_locations(last_used_at desc);
|
|
674
|
+
create index if not exists idx_geocoded_unresolved_ttl on geocoded_locations_unresolved(ttl_until desc);
|
|
614
675
|
`;
|
|
615
676
|
|
|
616
677
|
function getColumnNames(db: Database, tableName: string): Set<string> {
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getNativeDb } from "./db";
|
|
3
|
+
import { coordinatesFromLocationKey, normalizeLocationKey } from "./location";
|
|
4
|
+
import type { Database } from "./sqlite";
|
|
5
|
+
|
|
6
|
+
const OpenCageGeometrySchema = z.object({ lat: z.number(), lng: z.number() });
|
|
7
|
+
const OpenCageResultSchema = z.object({
|
|
8
|
+
geometry: OpenCageGeometrySchema,
|
|
9
|
+
confidence: z.number().int().min(0).max(10).optional(),
|
|
10
|
+
formatted: z.string().optional(),
|
|
11
|
+
components: z
|
|
12
|
+
.object({ country_code: z.string().optional() })
|
|
13
|
+
.loose()
|
|
14
|
+
.optional(),
|
|
15
|
+
bounds: z
|
|
16
|
+
.object({
|
|
17
|
+
northeast: OpenCageGeometrySchema.optional(),
|
|
18
|
+
southwest: OpenCageGeometrySchema.optional(),
|
|
19
|
+
})
|
|
20
|
+
.optional(),
|
|
21
|
+
});
|
|
22
|
+
const OpenCageResponseSchema = z.object({
|
|
23
|
+
results: z.array(OpenCageResultSchema),
|
|
24
|
+
status: z.object({ code: z.number(), message: z.string() }).loose(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface GeocodeResult {
|
|
28
|
+
normalizedKey: string;
|
|
29
|
+
original: string;
|
|
30
|
+
lat: number;
|
|
31
|
+
lng: number;
|
|
32
|
+
formatted?: string;
|
|
33
|
+
countryCode?: string;
|
|
34
|
+
confidence?: number;
|
|
35
|
+
provider: "opencage" | "coords";
|
|
36
|
+
approxRadiusM?: number;
|
|
37
|
+
bounds?: Record<string, unknown>;
|
|
38
|
+
components?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class GeocodeRateLimitError extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super("opencage:429");
|
|
44
|
+
this.name = "GeocodeRateLimitError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SQLITE_IN_CHUNK_SIZE = 400;
|
|
49
|
+
|
|
50
|
+
export function getOpenCageApiKey() {
|
|
51
|
+
return process.env.OPENCAGE_API_KEY?.trim() || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function approximateRadiusM(bounds: GeocodeResult["bounds"]) {
|
|
55
|
+
const northeast = bounds?.northeast as
|
|
56
|
+
| { lat?: unknown; lng?: unknown }
|
|
57
|
+
| undefined;
|
|
58
|
+
const southwest = bounds?.southwest as
|
|
59
|
+
| { lat?: unknown; lng?: unknown }
|
|
60
|
+
| undefined;
|
|
61
|
+
if (
|
|
62
|
+
typeof northeast?.lat !== "number" ||
|
|
63
|
+
typeof northeast.lng !== "number" ||
|
|
64
|
+
typeof southwest?.lat !== "number" ||
|
|
65
|
+
typeof southwest.lng !== "number"
|
|
66
|
+
) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const latMeters = Math.abs(northeast.lat - southwest.lat) * 111_320;
|
|
70
|
+
const lngMeters =
|
|
71
|
+
Math.abs(northeast.lng - southwest.lng) *
|
|
72
|
+
Math.cos((((northeast.lat + southwest.lat) / 2) * Math.PI) / 180) *
|
|
73
|
+
111_320;
|
|
74
|
+
return Math.max(500, Math.min(120_000, Math.hypot(latMeters, lngMeters) / 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readCachedGeocodes(
|
|
78
|
+
keys: readonly string[],
|
|
79
|
+
db = getNativeDb(),
|
|
80
|
+
) {
|
|
81
|
+
if (keys.length === 0) return new Map<string, GeocodeResult>();
|
|
82
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
83
|
+
for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
|
|
84
|
+
const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
|
|
85
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
86
|
+
rows.push(
|
|
87
|
+
...(db
|
|
88
|
+
.prepare(
|
|
89
|
+
`
|
|
90
|
+
select normalized_key, original, lat, lng, formatted, country_code,
|
|
91
|
+
confidence, provider, approx_radius_m, bounds_json, components_json
|
|
92
|
+
from geocoded_locations
|
|
93
|
+
where normalized_key in (${placeholders})
|
|
94
|
+
`,
|
|
95
|
+
)
|
|
96
|
+
.all(...chunk) as Array<Record<string, unknown>>),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const map = new Map<string, GeocodeResult>();
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const key = String(row.normalized_key);
|
|
102
|
+
map.set(key, {
|
|
103
|
+
normalizedKey: key,
|
|
104
|
+
original: String(row.original),
|
|
105
|
+
lat: Number(row.lat),
|
|
106
|
+
lng: Number(row.lng),
|
|
107
|
+
formatted: typeof row.formatted === "string" ? row.formatted : undefined,
|
|
108
|
+
countryCode:
|
|
109
|
+
typeof row.country_code === "string" ? row.country_code : undefined,
|
|
110
|
+
confidence:
|
|
111
|
+
typeof row.confidence === "number" ? row.confidence : undefined,
|
|
112
|
+
provider: row.provider === "coords" ? "coords" : "opencage",
|
|
113
|
+
approxRadiusM:
|
|
114
|
+
typeof row.approx_radius_m === "number"
|
|
115
|
+
? row.approx_radius_m
|
|
116
|
+
: undefined,
|
|
117
|
+
bounds: parseJsonRecord(row.bounds_json),
|
|
118
|
+
components: parseJsonRecord(row.components_json),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return map;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readSuppressedGeocodeKeys(
|
|
125
|
+
keys: readonly string[],
|
|
126
|
+
db = getNativeDb(),
|
|
127
|
+
) {
|
|
128
|
+
if (keys.length === 0) return new Set<string>();
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
const rows: Array<{ normalized_key: string }> = [];
|
|
131
|
+
for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
|
|
132
|
+
const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
|
|
133
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
134
|
+
rows.push(
|
|
135
|
+
...(db
|
|
136
|
+
.prepare(
|
|
137
|
+
`
|
|
138
|
+
select normalized_key
|
|
139
|
+
from geocoded_locations_unresolved
|
|
140
|
+
where normalized_key in (${placeholders})
|
|
141
|
+
and (ttl_until is null or ttl_until > ?)
|
|
142
|
+
`,
|
|
143
|
+
)
|
|
144
|
+
.all(...chunk, now) as Array<{ normalized_key: string }>),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return new Set(rows.map((row) => row.normalized_key));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function storeGeocode(result: GeocodeResult, db = getNativeDb()) {
|
|
151
|
+
const now = new Date().toISOString();
|
|
152
|
+
db.prepare(
|
|
153
|
+
`
|
|
154
|
+
insert into geocoded_locations (
|
|
155
|
+
normalized_key, original, lat, lng, formatted, country_code, confidence,
|
|
156
|
+
provider, approx_radius_m, bounds_json, components_json, hits, created_at,
|
|
157
|
+
last_used_at
|
|
158
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
159
|
+
on conflict(normalized_key) do update set
|
|
160
|
+
original = excluded.original,
|
|
161
|
+
lat = excluded.lat,
|
|
162
|
+
lng = excluded.lng,
|
|
163
|
+
formatted = coalesce(excluded.formatted, geocoded_locations.formatted),
|
|
164
|
+
country_code = coalesce(excluded.country_code, geocoded_locations.country_code),
|
|
165
|
+
confidence = coalesce(excluded.confidence, geocoded_locations.confidence),
|
|
166
|
+
provider = excluded.provider,
|
|
167
|
+
approx_radius_m = coalesce(excluded.approx_radius_m, geocoded_locations.approx_radius_m),
|
|
168
|
+
bounds_json = excluded.bounds_json,
|
|
169
|
+
components_json = excluded.components_json,
|
|
170
|
+
hits = geocoded_locations.hits + 1,
|
|
171
|
+
last_used_at = excluded.last_used_at
|
|
172
|
+
`,
|
|
173
|
+
).run(
|
|
174
|
+
result.normalizedKey,
|
|
175
|
+
result.original,
|
|
176
|
+
result.lat,
|
|
177
|
+
result.lng,
|
|
178
|
+
result.formatted ?? null,
|
|
179
|
+
result.countryCode?.toUpperCase() ?? null,
|
|
180
|
+
result.confidence ?? null,
|
|
181
|
+
result.provider,
|
|
182
|
+
result.approxRadiusM ?? null,
|
|
183
|
+
JSON.stringify(result.bounds ?? {}),
|
|
184
|
+
JSON.stringify(result.components ?? {}),
|
|
185
|
+
now,
|
|
186
|
+
now,
|
|
187
|
+
);
|
|
188
|
+
db.prepare(
|
|
189
|
+
"delete from geocoded_locations_unresolved where normalized_key = ?",
|
|
190
|
+
).run(result.normalizedKey);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function storeUnresolvedGeocode(
|
|
194
|
+
normalizedKey: string,
|
|
195
|
+
original: string,
|
|
196
|
+
reason: string,
|
|
197
|
+
db: Database = getNativeDb(),
|
|
198
|
+
) {
|
|
199
|
+
const now = new Date().toISOString();
|
|
200
|
+
const ttl = new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString();
|
|
201
|
+
db.prepare(
|
|
202
|
+
`
|
|
203
|
+
insert into geocoded_locations_unresolved (
|
|
204
|
+
normalized_key, original, reason, last_attempted_at, ttl_until
|
|
205
|
+
) values (?, ?, ?, ?, ?)
|
|
206
|
+
on conflict(normalized_key) do update set
|
|
207
|
+
original = excluded.original,
|
|
208
|
+
reason = excluded.reason,
|
|
209
|
+
last_attempted_at = excluded.last_attempted_at,
|
|
210
|
+
ttl_until = excluded.ttl_until
|
|
211
|
+
`,
|
|
212
|
+
).run(normalizedKey, original, reason, now, ttl);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function geocodeLocation(
|
|
216
|
+
original: string,
|
|
217
|
+
db: Database = getNativeDb(),
|
|
218
|
+
signal?: AbortSignal,
|
|
219
|
+
): Promise<GeocodeResult | null> {
|
|
220
|
+
if (signal?.aborted) throw new Error("geocode aborted");
|
|
221
|
+
const normalizedKey = normalizeLocationKey(original);
|
|
222
|
+
if (!normalizedKey) return null;
|
|
223
|
+
|
|
224
|
+
const coord = coordinatesFromLocationKey(normalizedKey);
|
|
225
|
+
if (coord) {
|
|
226
|
+
const result: GeocodeResult = {
|
|
227
|
+
normalizedKey,
|
|
228
|
+
original,
|
|
229
|
+
lat: coord.lat,
|
|
230
|
+
lng: coord.lng,
|
|
231
|
+
formatted: original,
|
|
232
|
+
provider: "coords",
|
|
233
|
+
approxRadiusM: 500,
|
|
234
|
+
};
|
|
235
|
+
storeGeocode(result, db);
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const key = getOpenCageApiKey();
|
|
240
|
+
if (!key) return null;
|
|
241
|
+
|
|
242
|
+
const url = new URL("https://api.opencagedata.com/geocode/v1/json");
|
|
243
|
+
url.searchParams.set("q", original);
|
|
244
|
+
url.searchParams.set("key", key);
|
|
245
|
+
url.searchParams.set("limit", "1");
|
|
246
|
+
url.searchParams.set("no_annotations", "1");
|
|
247
|
+
|
|
248
|
+
const response = await fetch(url, { signal });
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
if (response.status === 429) {
|
|
251
|
+
throw new GeocodeRateLimitError();
|
|
252
|
+
}
|
|
253
|
+
storeUnresolvedGeocode(
|
|
254
|
+
normalizedKey,
|
|
255
|
+
original,
|
|
256
|
+
`opencage:${String(response.status)}`,
|
|
257
|
+
db,
|
|
258
|
+
);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const payload = OpenCageResponseSchema.parse(await response.json());
|
|
262
|
+
const first = payload.results[0];
|
|
263
|
+
if (!first) {
|
|
264
|
+
storeUnresolvedGeocode(normalizedKey, original, "no-result", db);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const bounds = first.bounds as Record<string, unknown> | undefined;
|
|
269
|
+
const result: GeocodeResult = {
|
|
270
|
+
normalizedKey,
|
|
271
|
+
original,
|
|
272
|
+
lat: first.geometry.lat,
|
|
273
|
+
lng: first.geometry.lng,
|
|
274
|
+
formatted: first.formatted,
|
|
275
|
+
countryCode: first.components?.country_code?.toUpperCase(),
|
|
276
|
+
confidence: first.confidence,
|
|
277
|
+
provider: "opencage",
|
|
278
|
+
approxRadiusM: approximateRadiusM(bounds),
|
|
279
|
+
bounds,
|
|
280
|
+
components: first.components as Record<string, unknown> | undefined,
|
|
281
|
+
};
|
|
282
|
+
storeGeocode(result, db);
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseJsonRecord(value: unknown) {
|
|
287
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(value) as unknown;
|
|
290
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
291
|
+
? (parsed as Record<string, unknown>)
|
|
292
|
+
: undefined;
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|