thinking-phrases 1.0.1
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/README.md +370 -0
- package/bin/thinking-phrases.ts +8 -0
- package/configs/hn-top.config.json +83 -0
- package/launchd/com.austenstone.thinking-phrases.rss.plist +29 -0
- package/launchd/rss-update.error.log +27 -0
- package/launchd/rss-update.log +0 -0
- package/package.json +66 -0
- package/scripts/build.ts +96 -0
- package/scripts/install-rss-updater.zsh +69 -0
- package/scripts/run-rss-update.zsh +16 -0
- package/scripts/uninstall-rss-updater.zsh +11 -0
- package/scripts/update-rss-settings.ts +7 -0
- package/src/core/config.ts +704 -0
- package/src/core/githubModels.ts +208 -0
- package/src/core/interactive.ts +1053 -0
- package/src/core/presets.ts +77 -0
- package/src/core/runner.ts +375 -0
- package/src/core/scheduler.ts +84 -0
- package/src/core/sourceCatalog.ts +18 -0
- package/src/core/staticPacks.ts +66 -0
- package/src/core/types.ts +177 -0
- package/src/core/utils.ts +312 -0
- package/src/sinks/vscodeSettings.ts +44 -0
- package/src/sources/customJson.ts +174 -0
- package/src/sources/earthquakes.ts +100 -0
- package/src/sources/githubActivity.ts +598 -0
- package/src/sources/hackerNews.ts +75 -0
- package/src/sources/rss.ts +256 -0
- package/src/sources/stocks.ts +120 -0
- package/src/sources/weatherAlerts.ts +111 -0
- package/tips/dwyl-quotes.json +1616 -0
- package/tips/javascript-tips.json +102 -0
- package/tips/league-loading-screen-tips.json +102 -0
- package/tips/ruby-tips.json +110 -0
- package/tips/typescript-tips.json +126 -0
- package/tips/vscode/copilot.json +77 -0
- package/tips/vscode/debugging.json +37 -0
- package/tips/vscode/editor.json +52 -0
- package/tips/vscode/funny.json +42 -0
- package/tips/vscode/git.json +42 -0
- package/tips/vscode/shortcuts.json +127 -0
- package/tips/vscode/terminal.json +37 -0
- package/tips/wow-loading-screen-tips.json +111 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export type Mode = 'append' | 'replace';
|
|
2
|
+
export type Target = 'auto' | 'insiders' | 'stable';
|
|
3
|
+
export type HackerNewsFeed = 'top' | 'new' | 'best' | 'ask' | 'show' | 'jobs';
|
|
4
|
+
export type EarthquakeOrder = 'time' | 'magnitude';
|
|
5
|
+
export type WeatherSeverity = 'minor' | 'moderate' | 'severe' | 'extreme';
|
|
6
|
+
export type GitHubActivityMode = 'repo-commits' | 'org-commits' | 'feed';
|
|
7
|
+
export type GitHubFeedKind = 'timeline' | 'current-user-public' | 'current-user' | 'current-user-actor' | 'security-advisories' | 'organization' | 'custom-url';
|
|
8
|
+
|
|
9
|
+
export interface FeedConfig {
|
|
10
|
+
url: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PhraseFormatting {
|
|
15
|
+
includeSource: boolean;
|
|
16
|
+
includeTime: boolean;
|
|
17
|
+
maxLength: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GitHubModelsConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
model: string;
|
|
23
|
+
tokenEnvVar: string;
|
|
24
|
+
maxInputItems: number;
|
|
25
|
+
maxTokens: number;
|
|
26
|
+
maxPhrasesPerArticle: number;
|
|
27
|
+
temperature: number;
|
|
28
|
+
fetchArticleContent: boolean;
|
|
29
|
+
maxArticleContentLength: number;
|
|
30
|
+
systemPrompt?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StockQuoteConfig {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
symbols: string[];
|
|
36
|
+
includeMarketState: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HackerNewsConfig {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
feed: HackerNewsFeed;
|
|
42
|
+
maxItems: number;
|
|
43
|
+
minScore: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EarthquakeConfig {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
zipCode?: string;
|
|
49
|
+
minMagnitude: number;
|
|
50
|
+
windowHours: number;
|
|
51
|
+
limit: number;
|
|
52
|
+
place?: string;
|
|
53
|
+
radiusKm: number;
|
|
54
|
+
orderBy: EarthquakeOrder;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WeatherAlertsConfig {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
zipCode?: string;
|
|
60
|
+
area?: string;
|
|
61
|
+
minimumSeverity: WeatherSeverity;
|
|
62
|
+
limit: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CustomJsonConfig {
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
url: string;
|
|
68
|
+
itemsPath?: string;
|
|
69
|
+
titleField: string;
|
|
70
|
+
contentField?: string;
|
|
71
|
+
linkField?: string;
|
|
72
|
+
sourceField?: string;
|
|
73
|
+
sourceLabel?: string;
|
|
74
|
+
dateField?: string;
|
|
75
|
+
idField?: string;
|
|
76
|
+
maxItems: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface GitHubActivityConfig {
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
mode: GitHubActivityMode;
|
|
82
|
+
repo?: string;
|
|
83
|
+
org?: string;
|
|
84
|
+
branch?: string;
|
|
85
|
+
feedKind: GitHubFeedKind;
|
|
86
|
+
feedUrl?: string;
|
|
87
|
+
maxItems: number;
|
|
88
|
+
sinceHours: number;
|
|
89
|
+
tokenEnvVar: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Config {
|
|
93
|
+
feeds: FeedConfig[];
|
|
94
|
+
limit: number;
|
|
95
|
+
mode: Mode;
|
|
96
|
+
target: Target;
|
|
97
|
+
settingsPath?: string;
|
|
98
|
+
verbose?: boolean;
|
|
99
|
+
debug?: boolean;
|
|
100
|
+
phraseFormatting: PhraseFormatting;
|
|
101
|
+
githubModels: GitHubModelsConfig;
|
|
102
|
+
stockQuotes: StockQuoteConfig;
|
|
103
|
+
hackerNews: HackerNewsConfig;
|
|
104
|
+
earthquakes: EarthquakeConfig;
|
|
105
|
+
weatherAlerts: WeatherAlertsConfig;
|
|
106
|
+
customJson: CustomJsonConfig;
|
|
107
|
+
githubActivity: GitHubActivityConfig;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CliOverrides extends Partial<Config> {
|
|
111
|
+
dryRun?: boolean;
|
|
112
|
+
interactive?: boolean;
|
|
113
|
+
uninstall?: boolean;
|
|
114
|
+
createNewConfig?: boolean;
|
|
115
|
+
installScheduler?: boolean;
|
|
116
|
+
uninstallScheduler?: boolean;
|
|
117
|
+
schedulerIntervalSeconds?: number;
|
|
118
|
+
configPath?: string;
|
|
119
|
+
schedulerConfigPath?: string;
|
|
120
|
+
staticPackPath?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface GitHubModelsResponse {
|
|
124
|
+
choices?: Array<{
|
|
125
|
+
message?: {
|
|
126
|
+
content?: string;
|
|
127
|
+
};
|
|
128
|
+
}>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ArticleItem {
|
|
132
|
+
type: 'article';
|
|
133
|
+
id: string;
|
|
134
|
+
title?: string;
|
|
135
|
+
displayPhrase?: string;
|
|
136
|
+
link?: string;
|
|
137
|
+
source?: string;
|
|
138
|
+
datetime?: string;
|
|
139
|
+
time?: string;
|
|
140
|
+
content?: string;
|
|
141
|
+
articleContent?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface StockItem {
|
|
145
|
+
type: 'stock';
|
|
146
|
+
id: string;
|
|
147
|
+
symbol: string;
|
|
148
|
+
price: number;
|
|
149
|
+
currency?: string;
|
|
150
|
+
changePercent?: number;
|
|
151
|
+
marketLabel?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type PhraseItem = ArticleItem | StockItem;
|
|
155
|
+
|
|
156
|
+
export interface PhraseSource {
|
|
157
|
+
type: string;
|
|
158
|
+
isEnabled(config: Config): boolean;
|
|
159
|
+
fetch(config: Config): Promise<PhraseItem[]>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface InstalledSchedulerInfo {
|
|
163
|
+
installed: boolean;
|
|
164
|
+
label: string;
|
|
165
|
+
plistPath: string;
|
|
166
|
+
intervalSeconds?: number;
|
|
167
|
+
configPath?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface StaticPackInfo {
|
|
171
|
+
name: string;
|
|
172
|
+
fileName: string;
|
|
173
|
+
path: string;
|
|
174
|
+
mode: Mode;
|
|
175
|
+
phrases: string[];
|
|
176
|
+
}
|
|
177
|
+
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import type { Config, Target } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface ZipLocation {
|
|
8
|
+
zipCode: string;
|
|
9
|
+
placeName: string;
|
|
10
|
+
state: string;
|
|
11
|
+
stateAbbreviation: string;
|
|
12
|
+
latitude: number;
|
|
13
|
+
longitude: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const USER_AGENT = 'thinking-phrases/1.0 (+https://github.com/austenstone/thinking-phrases)';
|
|
17
|
+
|
|
18
|
+
export function logInfo(config: Pick<Config, 'verbose'>, message: string): void {
|
|
19
|
+
if (config.verbose) {
|
|
20
|
+
console.log(`[phrases] ${message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function logDebug(config: Pick<Config, 'debug'>, message: string): void {
|
|
25
|
+
if (config.debug) {
|
|
26
|
+
console.log(`[phrases:debug] ${message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadDotEnv(filePath: string): void {
|
|
31
|
+
if (!existsSync(filePath)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const separatorIndex = trimmed.indexOf('=');
|
|
42
|
+
if (separatorIndex <= 0) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
47
|
+
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/gu, '');
|
|
48
|
+
if (!(key in process.env)) {
|
|
49
|
+
process.env[key] = value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function expandHome(filePath: string): string {
|
|
55
|
+
return filePath.startsWith('~/') ? join(homedir(), filePath.slice(2)) : filePath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveSettingsPath(target: Target, explicitPath?: string): string {
|
|
59
|
+
if (explicitPath) {
|
|
60
|
+
const expanded = expandHome(explicitPath);
|
|
61
|
+
return isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const home = homedir();
|
|
65
|
+
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
66
|
+
const candidates = {
|
|
67
|
+
insiders: [
|
|
68
|
+
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
|
|
69
|
+
join(home, '.config', 'Code - Insiders', 'User', 'settings.json'),
|
|
70
|
+
join(appData, 'Code - Insiders', 'User', 'settings.json'),
|
|
71
|
+
],
|
|
72
|
+
stable: [
|
|
73
|
+
join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
|
|
74
|
+
join(home, '.config', 'Code', 'User', 'settings.json'),
|
|
75
|
+
join(appData, 'Code', 'User', 'settings.json'),
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const firstExisting = (paths: string[]) => paths.find(path => existsSync(path));
|
|
80
|
+
|
|
81
|
+
if (target === 'insiders') {
|
|
82
|
+
return firstExisting(candidates.insiders) ?? candidates.insiders[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (target === 'stable') {
|
|
86
|
+
return firstExisting(candidates.stable) ?? candidates.stable[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return firstExisting([...candidates.insiders, ...candidates.stable]) ?? candidates.insiders[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function truncate(input: string, maxLength: number): string {
|
|
93
|
+
if (input.length <= maxLength) {
|
|
94
|
+
return input;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function singleLine(input: string, maxLength = input.length): string {
|
|
101
|
+
return truncate(input.replace(/\s+/gu, ' ').trim(), maxLength);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function decodeHtmlEntities(input: string): string {
|
|
105
|
+
const named: Record<string, string> = {
|
|
106
|
+
amp: '&',
|
|
107
|
+
apos: "'",
|
|
108
|
+
gt: '>',
|
|
109
|
+
lt: '<',
|
|
110
|
+
nbsp: ' ',
|
|
111
|
+
quot: '"',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return input.replace(/&(#x?[0-9a-f]+|[a-z]+);/giu, (match, entity: string) => {
|
|
115
|
+
const normalized = entity.toLowerCase();
|
|
116
|
+
if (normalized in named) {
|
|
117
|
+
return named[normalized];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!normalized.startsWith('#')) {
|
|
121
|
+
return match;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isHex = normalized.startsWith('#x');
|
|
125
|
+
const codePoint = Number.parseInt(normalized.slice(isHex ? 2 : 1), isHex ? 16 : 10);
|
|
126
|
+
if (Number.isNaN(codePoint)) {
|
|
127
|
+
return match;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return String.fromCodePoint(codePoint);
|
|
132
|
+
} catch {
|
|
133
|
+
return match;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function stripHtml(input?: string): string | undefined {
|
|
139
|
+
if (!input) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cleaned = decodeHtmlEntities(input)
|
|
144
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, ' ')
|
|
145
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, ' ')
|
|
146
|
+
.replace(/<[^>]+>/gu, ' ')
|
|
147
|
+
.replace(/\s+/gu, ' ')
|
|
148
|
+
.trim();
|
|
149
|
+
|
|
150
|
+
return cleaned || undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function relativeTime(input?: string): string | undefined {
|
|
154
|
+
if (!input) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const date = new Date(input);
|
|
159
|
+
if (Number.isNaN(date.getTime())) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const minutes = Math.max(1, Math.round((Date.now() - date.getTime()) / 60_000));
|
|
164
|
+
if (minutes < 60) {
|
|
165
|
+
return `${minutes}m ago`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const hours = Math.round(minutes / 60);
|
|
169
|
+
if (hours < 24) {
|
|
170
|
+
return `${hours}h ago`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function dedupePhrases(phrases: string[]): string[] {
|
|
177
|
+
const seen = new Set<string>();
|
|
178
|
+
const unique: string[] = [];
|
|
179
|
+
|
|
180
|
+
for (const phrase of phrases) {
|
|
181
|
+
const normalized = phrase.toLowerCase();
|
|
182
|
+
if (seen.has(normalized)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
seen.add(normalized);
|
|
187
|
+
unique.push(phrase);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return unique;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function normalizeSymbols(symbols: string[]): string[] {
|
|
194
|
+
return Array.from(new Set(symbols.map(symbol => symbol.trim().toUpperCase()).filter(Boolean)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function normalizeUsZipCode(input?: string): string | undefined {
|
|
198
|
+
const digits = input?.replace(/\D+/gu, '').slice(0, 5);
|
|
199
|
+
return digits && digits.length === 5 ? digits : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isValidUsZipCode(input?: string): boolean {
|
|
203
|
+
return Boolean(normalizeUsZipCode(input));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatPrice(value: number, currency?: string): string {
|
|
207
|
+
const normalizedCurrency = currency?.trim().toUpperCase() || 'USD';
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
return new Intl.NumberFormat('en-US', {
|
|
211
|
+
style: 'currency',
|
|
212
|
+
currency: normalizedCurrency,
|
|
213
|
+
minimumFractionDigits: value < 1 ? 4 : 2,
|
|
214
|
+
maximumFractionDigits: value < 1 ? 4 : 2,
|
|
215
|
+
}).format(value);
|
|
216
|
+
} catch {
|
|
217
|
+
return `${value.toFixed(value < 1 ? 4 : 2)} ${normalizedCurrency}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatSignedPercent(value?: number): string | undefined {
|
|
222
|
+
if (!Number.isFinite(value)) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalizedValue = value as number;
|
|
227
|
+
const arrow = normalizedValue > 0 ? '▲' : normalizedValue < 0 ? '▼' : '•';
|
|
228
|
+
return `${arrow} ${Math.abs(normalizedValue).toFixed(2)}%`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function fetchText(url: string, headers?: Record<string, string>): Promise<string> {
|
|
232
|
+
const response = await fetch(url, {
|
|
233
|
+
headers: { 'user-agent': USER_AGENT, ...(headers ?? {}) },
|
|
234
|
+
signal: AbortSignal.timeout(15_000),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
throw new Error(`Request failed for ${url} (${response.status})`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return response.text();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function fetchJson<T>(url: string, headers?: Record<string, string>): Promise<T> {
|
|
245
|
+
const response = await fetch(url, {
|
|
246
|
+
headers: {
|
|
247
|
+
'user-agent': USER_AGENT,
|
|
248
|
+
accept: 'application/json',
|
|
249
|
+
...(headers ?? {}),
|
|
250
|
+
},
|
|
251
|
+
signal: AbortSignal.timeout(15_000),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (!response.ok) {
|
|
255
|
+
throw new Error(`Request failed for ${url} (${response.status})`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return response.json() as Promise<T>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface ZippopotamResponse {
|
|
262
|
+
country?: string;
|
|
263
|
+
'post code'?: string;
|
|
264
|
+
places?: Array<{
|
|
265
|
+
latitude?: string;
|
|
266
|
+
longitude?: string;
|
|
267
|
+
'place name'?: string;
|
|
268
|
+
state?: string;
|
|
269
|
+
'state abbreviation'?: string;
|
|
270
|
+
}>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const zipLocationCache = new Map<string, Promise<ZipLocation>>();
|
|
274
|
+
|
|
275
|
+
export async function fetchUsZipLocation(zipCode: string): Promise<ZipLocation> {
|
|
276
|
+
const normalizedZip = normalizeUsZipCode(zipCode);
|
|
277
|
+
if (!normalizedZip) {
|
|
278
|
+
throw new Error(`Invalid ZIP code: ${zipCode}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const cached = zipLocationCache.get(normalizedZip);
|
|
282
|
+
if (cached) {
|
|
283
|
+
return cached;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const lookupPromise = fetchJson<ZippopotamResponse>(`https://api.zippopotam.us/us/${normalizedZip}`)
|
|
287
|
+
.then(payload => {
|
|
288
|
+
const place = payload.places?.[0];
|
|
289
|
+
const latitude = Number(place?.latitude);
|
|
290
|
+
const longitude = Number(place?.longitude);
|
|
291
|
+
|
|
292
|
+
if (!place || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
293
|
+
throw new Error(`Could not resolve ZIP code ${normalizedZip}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
zipCode: normalizedZip,
|
|
298
|
+
placeName: place['place name']?.trim() || normalizedZip,
|
|
299
|
+
state: place.state?.trim() || '',
|
|
300
|
+
stateAbbreviation: place['state abbreviation']?.trim().toUpperCase() || '',
|
|
301
|
+
latitude,
|
|
302
|
+
longitude,
|
|
303
|
+
} satisfies ZipLocation;
|
|
304
|
+
})
|
|
305
|
+
.catch(error => {
|
|
306
|
+
zipLocationCache.delete(normalizedZip);
|
|
307
|
+
throw error;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
zipLocationCache.set(normalizedZip, lookupPromise);
|
|
311
|
+
return lookupPromise;
|
|
312
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { applyEdits, modify } from 'jsonc-parser';
|
|
4
|
+
import type { Mode } from '../core/types.js';
|
|
5
|
+
|
|
6
|
+
export function writeVsCodeSettings(settingsPath: string, phrases: string[], mode: Mode): void {
|
|
7
|
+
const initialText = existsSync(settingsPath) ? readFileSync(settingsPath, 'utf8') : '{}\n';
|
|
8
|
+
const edits = modify(
|
|
9
|
+
initialText,
|
|
10
|
+
['chat.agent.thinking.phrases'],
|
|
11
|
+
{ mode, phrases },
|
|
12
|
+
{
|
|
13
|
+
formattingOptions: {
|
|
14
|
+
insertSpaces: true,
|
|
15
|
+
tabSize: 2,
|
|
16
|
+
eol: '\n',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const updatedText = applyEdits(initialText, edits);
|
|
22
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
23
|
+
writeFileSync(settingsPath, updatedText.endsWith('\n') ? updatedText : `${updatedText}\n`, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function removeVsCodeThinkingPhrases(settingsPath: string): boolean {
|
|
27
|
+
const initialText = existsSync(settingsPath) ? readFileSync(settingsPath, 'utf8') : '{}\n';
|
|
28
|
+
const edits = modify(initialText, ['chat.agent.thinking.phrases'], undefined, {
|
|
29
|
+
formattingOptions: {
|
|
30
|
+
insertSpaces: true,
|
|
31
|
+
tabSize: 2,
|
|
32
|
+
eol: '\n',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (edits.length === 0) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const updatedText = applyEdits(initialText, edits);
|
|
41
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
42
|
+
writeFileSync(settingsPath, updatedText.endsWith('\n') ? updatedText : `${updatedText}\n`, 'utf8');
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ArticleItem, Config, PhraseSource } from '../core/types.js';
|
|
2
|
+
import { fetchJson, logInfo, relativeTime, stripHtml, truncate } from '../core/utils.js';
|
|
3
|
+
|
|
4
|
+
type PathSegment = number | string;
|
|
5
|
+
|
|
6
|
+
const PATH_SEGMENT_PATTERN = /(?:^|\.)([^.[\]]+)|\[(\d+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\]/gu;
|
|
7
|
+
|
|
8
|
+
function parsePathSegments(path: string): PathSegment[] {
|
|
9
|
+
const normalizedPath = path.trim();
|
|
10
|
+
if (!normalizedPath) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const segments: PathSegment[] = [];
|
|
15
|
+
|
|
16
|
+
for (const match of normalizedPath.matchAll(PATH_SEGMENT_PATTERN)) {
|
|
17
|
+
const [, dotSegment, bracketSegment] = match;
|
|
18
|
+
if (dotSegment) {
|
|
19
|
+
segments.push(dotSegment);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!bracketSegment) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (/^\d+$/u.test(bracketSegment)) {
|
|
28
|
+
segments.push(Number(bracketSegment));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
segments.push(bracketSegment.slice(1, -1));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return segments;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getPathValue(input: unknown, path?: string): unknown {
|
|
39
|
+
if (!path?.trim()) {
|
|
40
|
+
return input;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const segments = parsePathSegments(path);
|
|
44
|
+
let current: unknown = input;
|
|
45
|
+
|
|
46
|
+
for (const segment of segments) {
|
|
47
|
+
if (typeof segment === 'number') {
|
|
48
|
+
if (!Array.isArray(current)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
current = current[segment];
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!current || typeof current !== 'object') {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
current = (current as Record<string, unknown>)[segment];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readStringValue(input: unknown, path?: string): string | undefined {
|
|
67
|
+
const value = getPathValue(input, path);
|
|
68
|
+
if (typeof value === 'string') {
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
return trimmed || undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toIsoDate(value: unknown): string | undefined {
|
|
81
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
82
|
+
const milliseconds = value >= 1_000_000_000_000 ? value : value >= 1_000_000_000 ? value * 1000 : NaN;
|
|
83
|
+
if (!Number.isFinite(milliseconds)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Date(milliseconds).toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof value !== 'string') {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const trimmed = value.trim();
|
|
95
|
+
if (!trimmed) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (/^\d+(\.\d+)?$/u.test(trimmed)) {
|
|
100
|
+
return toIsoDate(Number(trimmed));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = new Date(trimmed);
|
|
104
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildDefaultSourceLabel(url: string): string {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(url).hostname.replace(/^www\./u, '');
|
|
110
|
+
} catch {
|
|
111
|
+
return 'Custom JSON';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveItems(payload: unknown, path?: string): unknown[] {
|
|
116
|
+
const resolved = getPathValue(payload, path);
|
|
117
|
+
if (Array.isArray(resolved)) {
|
|
118
|
+
return resolved;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!path?.trim() && Array.isArray(payload)) {
|
|
122
|
+
return payload;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error(`Custom JSON items path did not resolve to an array${path?.trim() ? `: ${path}` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function fetchCustomJsonArticles(config: Config): Promise<ArticleItem[]> {
|
|
129
|
+
if (!config.customJson.enabled) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
logInfo(config, `Fetching custom JSON items from ${config.customJson.url}`);
|
|
134
|
+
const payload = await fetchJson<unknown>(config.customJson.url);
|
|
135
|
+
const items = resolveItems(payload, config.customJson.itemsPath).slice(0, config.customJson.maxItems);
|
|
136
|
+
const defaultSource = config.customJson.sourceLabel?.trim() || buildDefaultSourceLabel(config.customJson.url);
|
|
137
|
+
|
|
138
|
+
return items.flatMap((item, index) => {
|
|
139
|
+
if (!item || typeof item !== 'object') {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const title = readStringValue(item, config.customJson.titleField);
|
|
144
|
+
if (!title) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const content = stripHtml(readStringValue(item, config.customJson.contentField));
|
|
149
|
+
const datetime = toIsoDate(getPathValue(item, config.customJson.dateField));
|
|
150
|
+
const link = readStringValue(item, config.customJson.linkField);
|
|
151
|
+
const source = readStringValue(item, config.customJson.sourceField) ?? defaultSource;
|
|
152
|
+
const id = readStringValue(item, config.customJson.idField)
|
|
153
|
+
?? link
|
|
154
|
+
?? `${config.customJson.url}#${index}:${title}`;
|
|
155
|
+
|
|
156
|
+
return [{
|
|
157
|
+
type: 'article' as const,
|
|
158
|
+
id: `custom-json:${id}`,
|
|
159
|
+
title,
|
|
160
|
+
link,
|
|
161
|
+
source,
|
|
162
|
+
datetime,
|
|
163
|
+
time: relativeTime(datetime),
|
|
164
|
+
content,
|
|
165
|
+
articleContent: content ? truncate(content, config.githubModels.maxArticleContentLength) : undefined,
|
|
166
|
+
}];
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const customJsonSource: PhraseSource = {
|
|
171
|
+
type: 'custom-json',
|
|
172
|
+
isEnabled: config => config.customJson.enabled,
|
|
173
|
+
fetch: fetchCustomJsonArticles,
|
|
174
|
+
};
|