@mepuka/skygent 0.2.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/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const hashtagRegex =
|
|
2
|
+
/#[\p{L}\p{M}\p{Pc}\p{Po}\p{Pd}\p{S}\p{Extended_Pictographic}][\p{L}\p{M}\p{N}\p{Pc}\p{Po}\p{Pd}\p{S}\p{Extended_Pictographic}]*/gu;
|
|
3
|
+
const mentionRegex = /@([a-z0-9][a-z0-9.-]{1,63})/gi;
|
|
4
|
+
const urlRegex = /https?:\/\/[^\s)]+[^\s).,;:!?'"]/g;
|
|
5
|
+
|
|
6
|
+
export const extractHashtags = (text: string): ReadonlyArray<string> => {
|
|
7
|
+
const matches = text.match(hashtagRegex) ?? [];
|
|
8
|
+
return Array.from(new Set(matches));
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const extractMentions = (text: string): ReadonlyArray<string> => {
|
|
12
|
+
const matches = text.matchAll(mentionRegex);
|
|
13
|
+
const handles: Array<string> = [];
|
|
14
|
+
for (const match of matches) {
|
|
15
|
+
const handle = match[1];
|
|
16
|
+
if (handle) {
|
|
17
|
+
handles.push(handle.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return Array.from(new Set(handles));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const extractLinks = (text: string): ReadonlyArray<string> => {
|
|
24
|
+
const matches = text.match(urlRegex) ?? [];
|
|
25
|
+
return Array.from(new Set(matches));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const extractFromFacets = (facets?: ReadonlyArray<unknown>) => {
|
|
29
|
+
const hashtags = new Set<string>();
|
|
30
|
+
const links = new Set<string>();
|
|
31
|
+
const mentionDids = new Set<string>();
|
|
32
|
+
|
|
33
|
+
for (const facet of facets ?? []) {
|
|
34
|
+
if (!facet || typeof facet !== "object") continue;
|
|
35
|
+
const features = (facet as { readonly features?: ReadonlyArray<unknown> })
|
|
36
|
+
.features ?? [];
|
|
37
|
+
for (const feature of features) {
|
|
38
|
+
if (!feature || typeof feature !== "object") continue;
|
|
39
|
+
const candidate = feature as {
|
|
40
|
+
readonly did?: unknown;
|
|
41
|
+
readonly uri?: unknown;
|
|
42
|
+
readonly tag?: unknown;
|
|
43
|
+
};
|
|
44
|
+
if (typeof candidate.did === "string" && candidate.did.length > 0) {
|
|
45
|
+
mentionDids.add(candidate.did);
|
|
46
|
+
}
|
|
47
|
+
if (typeof candidate.uri === "string" && candidate.uri.length > 0) {
|
|
48
|
+
links.add(candidate.uri);
|
|
49
|
+
}
|
|
50
|
+
if (typeof candidate.tag === "string" && candidate.tag.length > 0) {
|
|
51
|
+
const tag = candidate.tag.startsWith("#")
|
|
52
|
+
? candidate.tag
|
|
53
|
+
: `#${candidate.tag}`;
|
|
54
|
+
hashtags.add(tag);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
hashtags: Array.from(hashtags),
|
|
61
|
+
links: Array.from(links),
|
|
62
|
+
mentionDids: Array.from(mentionDids)
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { Duration } from "effect";
|
|
2
|
+
import { type FilterExpr, isEffectfulFilter } from "./filter.js";
|
|
3
|
+
import type { FilterErrorPolicy } from "./policies.js";
|
|
4
|
+
|
|
5
|
+
export type FilterCondition = {
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly operator?: "AND" | "OR";
|
|
9
|
+
readonly negated?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FilterDescription = {
|
|
13
|
+
readonly filter: string;
|
|
14
|
+
readonly summary: string;
|
|
15
|
+
readonly conditions: ReadonlyArray<FilterCondition>;
|
|
16
|
+
readonly effectful: boolean;
|
|
17
|
+
readonly eventTimeCompatible: boolean;
|
|
18
|
+
readonly deriveTimeCompatible: boolean;
|
|
19
|
+
readonly complexity: "low" | "medium" | "high";
|
|
20
|
+
readonly conditionCount: number;
|
|
21
|
+
readonly negationCount: number;
|
|
22
|
+
readonly estimatedCost: "very low" | "low" | "medium" | "high";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const quoteValue = (value: string) => {
|
|
26
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
27
|
+
return `"${escaped}"`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const needsQuotes = (value: string) => /[\s,():]/.test(value);
|
|
31
|
+
|
|
32
|
+
const formatValue = (value: string) => (needsQuotes(value) ? quoteValue(value) : value);
|
|
33
|
+
|
|
34
|
+
const formatWithOptions = (key: string, value: string, options: string[]) => {
|
|
35
|
+
if (value.length === 0) {
|
|
36
|
+
return options.length > 0 ? `${key}:${options.join(",")}` : key;
|
|
37
|
+
}
|
|
38
|
+
const optionSuffix = options.length > 0 ? `,${options.join(",")}` : "";
|
|
39
|
+
return `${key}:${value}${optionSuffix}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const formatPolicy = (policy: FilterErrorPolicy) => {
|
|
43
|
+
switch (policy._tag) {
|
|
44
|
+
case "Include":
|
|
45
|
+
return "include";
|
|
46
|
+
case "Exclude":
|
|
47
|
+
return "exclude";
|
|
48
|
+
case "Retry": {
|
|
49
|
+
const delayMs = Duration.toMillis(policy.baseDelay);
|
|
50
|
+
const delayValue = formatValue(`${delayMs} millis`);
|
|
51
|
+
return `retry,maxRetries=${policy.maxRetries},baseDelay=${delayValue}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const isDefaultPolicy = (tag: "HasValidLinks" | "Trending", policy: FilterErrorPolicy) => {
|
|
57
|
+
switch (tag) {
|
|
58
|
+
case "HasValidLinks":
|
|
59
|
+
return policy._tag === "Exclude";
|
|
60
|
+
case "Trending":
|
|
61
|
+
return policy._tag === "Include";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const formatEngagement = (expr: {
|
|
66
|
+
readonly minLikes?: number;
|
|
67
|
+
readonly minReposts?: number;
|
|
68
|
+
readonly minReplies?: number;
|
|
69
|
+
}) => {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (expr.minLikes !== undefined) parts.push(`minLikes=${expr.minLikes}`);
|
|
72
|
+
if (expr.minReposts !== undefined) parts.push(`minReposts=${expr.minReposts}`);
|
|
73
|
+
if (expr.minReplies !== undefined) parts.push(`minReplies=${expr.minReplies}`);
|
|
74
|
+
return parts.join(",");
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const formatRegex = (pattern: string, flags?: string) => {
|
|
78
|
+
const escaped = pattern.replace(/\//g, "\\/");
|
|
79
|
+
return `/${escaped}/${flags ?? ""}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const formatLeafValue = (expr: FilterExpr): string => {
|
|
83
|
+
switch (expr._tag) {
|
|
84
|
+
case "Author":
|
|
85
|
+
return expr.handle;
|
|
86
|
+
case "Hashtag":
|
|
87
|
+
return expr.tag;
|
|
88
|
+
case "AuthorIn":
|
|
89
|
+
return expr.handles.join(", ");
|
|
90
|
+
case "HashtagIn":
|
|
91
|
+
return expr.tags.join(", ");
|
|
92
|
+
case "Contains": {
|
|
93
|
+
const text = formatValue(expr.text);
|
|
94
|
+
return expr.caseSensitive !== undefined
|
|
95
|
+
? `${text} (caseSensitive=${expr.caseSensitive})`
|
|
96
|
+
: text;
|
|
97
|
+
}
|
|
98
|
+
case "IsReply":
|
|
99
|
+
return "reply";
|
|
100
|
+
case "IsQuote":
|
|
101
|
+
return "quote";
|
|
102
|
+
case "IsRepost":
|
|
103
|
+
return "repost";
|
|
104
|
+
case "IsOriginal":
|
|
105
|
+
return "original";
|
|
106
|
+
case "Engagement":
|
|
107
|
+
return formatEngagement(expr);
|
|
108
|
+
case "HasImages":
|
|
109
|
+
return "images";
|
|
110
|
+
case "HasVideo":
|
|
111
|
+
return "video";
|
|
112
|
+
case "HasLinks":
|
|
113
|
+
return "links";
|
|
114
|
+
case "HasMedia":
|
|
115
|
+
return "media";
|
|
116
|
+
case "HasEmbed":
|
|
117
|
+
return "embed";
|
|
118
|
+
case "Language":
|
|
119
|
+
return expr.langs.join(", ");
|
|
120
|
+
case "Regex": {
|
|
121
|
+
const pattern = expr.patterns.length > 1 ? expr.patterns.join("|") : expr.patterns[0] ?? "";
|
|
122
|
+
return formatRegex(pattern, expr.flags);
|
|
123
|
+
}
|
|
124
|
+
case "DateRange":
|
|
125
|
+
return `${expr.start.toISOString()}..${expr.end.toISOString()}`;
|
|
126
|
+
case "HasValidLinks":
|
|
127
|
+
return "valid links";
|
|
128
|
+
case "Trending":
|
|
129
|
+
return expr.tag;
|
|
130
|
+
case "All":
|
|
131
|
+
return "all";
|
|
132
|
+
case "None":
|
|
133
|
+
return "none";
|
|
134
|
+
case "And":
|
|
135
|
+
case "Or":
|
|
136
|
+
case "Not":
|
|
137
|
+
return formatFilterExpr(expr);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const formatLeafPhrase = (expr: FilterExpr): string => {
|
|
142
|
+
switch (expr._tag) {
|
|
143
|
+
case "Author":
|
|
144
|
+
return `from ${expr.handle}`;
|
|
145
|
+
case "Hashtag":
|
|
146
|
+
return `with hashtag ${expr.tag}`;
|
|
147
|
+
case "AuthorIn":
|
|
148
|
+
return `from authors ${expr.handles.join(", ")}`;
|
|
149
|
+
case "HashtagIn":
|
|
150
|
+
return `with hashtags ${expr.tags.join(", ")}`;
|
|
151
|
+
case "Contains":
|
|
152
|
+
return `containing ${formatValue(expr.text)}`;
|
|
153
|
+
case "IsReply":
|
|
154
|
+
return "that are replies";
|
|
155
|
+
case "IsQuote":
|
|
156
|
+
return "that are quotes";
|
|
157
|
+
case "IsRepost":
|
|
158
|
+
return "that are reposts";
|
|
159
|
+
case "IsOriginal":
|
|
160
|
+
return "that are original posts";
|
|
161
|
+
case "Engagement":
|
|
162
|
+
return `with ${formatEngagement(expr)} engagement`;
|
|
163
|
+
case "HasImages":
|
|
164
|
+
return "with images";
|
|
165
|
+
case "HasVideo":
|
|
166
|
+
return "with video";
|
|
167
|
+
case "HasLinks":
|
|
168
|
+
return "with links";
|
|
169
|
+
case "HasMedia":
|
|
170
|
+
return "with media";
|
|
171
|
+
case "HasEmbed":
|
|
172
|
+
return "with embeds";
|
|
173
|
+
case "Language":
|
|
174
|
+
return `in ${expr.langs.join(", ")} language`;
|
|
175
|
+
case "Regex": {
|
|
176
|
+
const pattern = expr.patterns.length > 1 ? expr.patterns.join("|") : expr.patterns[0] ?? "";
|
|
177
|
+
return `matching regex ${formatRegex(pattern, expr.flags)}`;
|
|
178
|
+
}
|
|
179
|
+
case "DateRange":
|
|
180
|
+
return `between ${expr.start.toISOString()} and ${expr.end.toISOString()}`;
|
|
181
|
+
case "HasValidLinks":
|
|
182
|
+
return "with valid links";
|
|
183
|
+
case "Trending":
|
|
184
|
+
return `matching trending ${expr.tag}`;
|
|
185
|
+
case "All":
|
|
186
|
+
return "that match all posts";
|
|
187
|
+
case "None":
|
|
188
|
+
return "that match no posts";
|
|
189
|
+
case "And":
|
|
190
|
+
case "Or":
|
|
191
|
+
case "Not":
|
|
192
|
+
return `matching ${formatFilterExpr(expr)}`;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const precedence = (expr: FilterExpr) => {
|
|
197
|
+
switch (expr._tag) {
|
|
198
|
+
case "Or":
|
|
199
|
+
return 1;
|
|
200
|
+
case "And":
|
|
201
|
+
return 2;
|
|
202
|
+
case "Not":
|
|
203
|
+
return 3;
|
|
204
|
+
default:
|
|
205
|
+
return 4;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const parenthesize = (value: string, parentPrec: number, currentPrec: number) =>
|
|
210
|
+
currentPrec < parentPrec ? `(${value})` : value;
|
|
211
|
+
|
|
212
|
+
export const formatFilterExpr = (expr: FilterExpr, parentPrec = 0): string => {
|
|
213
|
+
switch (expr._tag) {
|
|
214
|
+
case "All":
|
|
215
|
+
return "all";
|
|
216
|
+
case "None":
|
|
217
|
+
return "none";
|
|
218
|
+
case "And": {
|
|
219
|
+
const prec = precedence(expr);
|
|
220
|
+
const value = `${formatFilterExpr(expr.left, prec)} AND ${formatFilterExpr(expr.right, prec)}`;
|
|
221
|
+
return parenthesize(value, parentPrec, prec);
|
|
222
|
+
}
|
|
223
|
+
case "Or": {
|
|
224
|
+
const prec = precedence(expr);
|
|
225
|
+
const value = `${formatFilterExpr(expr.left, prec)} OR ${formatFilterExpr(expr.right, prec)}`;
|
|
226
|
+
return parenthesize(value, parentPrec, prec);
|
|
227
|
+
}
|
|
228
|
+
case "Not": {
|
|
229
|
+
const prec = precedence(expr);
|
|
230
|
+
const value = `NOT ${formatFilterExpr(expr.expr, prec)}`;
|
|
231
|
+
return parenthesize(value, parentPrec, prec);
|
|
232
|
+
}
|
|
233
|
+
case "Author":
|
|
234
|
+
return `author:${expr.handle}`;
|
|
235
|
+
case "Hashtag":
|
|
236
|
+
return `hashtag:${expr.tag}`;
|
|
237
|
+
case "AuthorIn":
|
|
238
|
+
return `authorin:${expr.handles.join(",")}`;
|
|
239
|
+
case "HashtagIn":
|
|
240
|
+
return `hashtagin:${expr.tags.join(",")}`;
|
|
241
|
+
case "Contains": {
|
|
242
|
+
const options: string[] = [];
|
|
243
|
+
if (expr.caseSensitive !== undefined) {
|
|
244
|
+
options.push(`caseSensitive=${expr.caseSensitive}`);
|
|
245
|
+
}
|
|
246
|
+
return formatWithOptions("contains", formatValue(expr.text), options);
|
|
247
|
+
}
|
|
248
|
+
case "IsReply":
|
|
249
|
+
return "is:reply";
|
|
250
|
+
case "IsQuote":
|
|
251
|
+
return "is:quote";
|
|
252
|
+
case "IsRepost":
|
|
253
|
+
return "is:repost";
|
|
254
|
+
case "IsOriginal":
|
|
255
|
+
return "is:original";
|
|
256
|
+
case "Engagement": {
|
|
257
|
+
const options = formatEngagement(expr);
|
|
258
|
+
return formatWithOptions("engagement", "", options.length > 0 ? [options] : []);
|
|
259
|
+
}
|
|
260
|
+
case "HasImages":
|
|
261
|
+
return "hasimages";
|
|
262
|
+
case "HasVideo":
|
|
263
|
+
return "hasvideo";
|
|
264
|
+
case "HasLinks":
|
|
265
|
+
return "haslinks";
|
|
266
|
+
case "HasMedia":
|
|
267
|
+
return "hasmedia";
|
|
268
|
+
case "HasEmbed":
|
|
269
|
+
return "hasembed";
|
|
270
|
+
case "Language":
|
|
271
|
+
return `language:${expr.langs.join(",")}`;
|
|
272
|
+
case "Regex": {
|
|
273
|
+
const pattern = expr.patterns.length > 1 ? expr.patterns.join("|") : expr.patterns[0] ?? "";
|
|
274
|
+
return `regex:${formatRegex(pattern, expr.flags)}`;
|
|
275
|
+
}
|
|
276
|
+
case "DateRange":
|
|
277
|
+
return `date:${expr.start.toISOString()}..${expr.end.toISOString()}`;
|
|
278
|
+
case "HasValidLinks": {
|
|
279
|
+
const options = isDefaultPolicy("HasValidLinks", expr.onError)
|
|
280
|
+
? []
|
|
281
|
+
: [`onError=${formatPolicy(expr.onError)}`];
|
|
282
|
+
return formatWithOptions("links", "", options);
|
|
283
|
+
}
|
|
284
|
+
case "Trending": {
|
|
285
|
+
const options = isDefaultPolicy("Trending", expr.onError)
|
|
286
|
+
? []
|
|
287
|
+
: [`onError=${formatPolicy(expr.onError)}`];
|
|
288
|
+
return formatWithOptions("trending", expr.tag, options);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const flattenAnd = (expr: FilterExpr): ReadonlyArray<FilterExpr> => {
|
|
294
|
+
if (expr._tag === "And") {
|
|
295
|
+
return [...flattenAnd(expr.left), ...flattenAnd(expr.right)];
|
|
296
|
+
}
|
|
297
|
+
return [expr];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const flattenOr = (expr: FilterExpr): ReadonlyArray<FilterExpr> => {
|
|
301
|
+
if (expr._tag === "Or") {
|
|
302
|
+
return [...flattenOr(expr.left), ...flattenOr(expr.right)];
|
|
303
|
+
}
|
|
304
|
+
return [expr];
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const countConditions = (expr: FilterExpr): number => {
|
|
308
|
+
switch (expr._tag) {
|
|
309
|
+
case "And":
|
|
310
|
+
case "Or":
|
|
311
|
+
return countConditions(expr.left) + countConditions(expr.right);
|
|
312
|
+
case "Not":
|
|
313
|
+
return countConditions(expr.expr);
|
|
314
|
+
case "All":
|
|
315
|
+
case "None":
|
|
316
|
+
return 0;
|
|
317
|
+
default:
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const countNegations = (expr: FilterExpr): number => {
|
|
323
|
+
switch (expr._tag) {
|
|
324
|
+
case "And":
|
|
325
|
+
case "Or":
|
|
326
|
+
return countNegations(expr.left) + countNegations(expr.right);
|
|
327
|
+
case "Not":
|
|
328
|
+
return 1 + countNegations(expr.expr);
|
|
329
|
+
default:
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const complexityFor = (conditions: number, negations: number): "low" | "medium" | "high" => {
|
|
335
|
+
if (conditions <= 2 && negations === 0) return "low";
|
|
336
|
+
if (conditions <= 4 && negations <= 1) return "medium";
|
|
337
|
+
return "high";
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const estimatedCostFor = (
|
|
341
|
+
effectful: boolean,
|
|
342
|
+
conditions: number
|
|
343
|
+
): "very low" | "low" | "medium" | "high" => {
|
|
344
|
+
if (effectful) return "high";
|
|
345
|
+
if (conditions <= 1) return "very low";
|
|
346
|
+
if (conditions <= 3) return "low";
|
|
347
|
+
if (conditions <= 6) return "medium";
|
|
348
|
+
return "high";
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const describeClause = (expr: FilterExpr): FilterCondition => {
|
|
352
|
+
if (expr._tag === "Not") {
|
|
353
|
+
const base = describeClause(expr.expr);
|
|
354
|
+
return { ...base, negated: true };
|
|
355
|
+
}
|
|
356
|
+
if (expr._tag === "Or") {
|
|
357
|
+
const terms = flattenOr(expr);
|
|
358
|
+
const firstType = terms[0]?._tag;
|
|
359
|
+
const allSame = terms.every((term) => term._tag === firstType);
|
|
360
|
+
if (allSame && firstType) {
|
|
361
|
+
return {
|
|
362
|
+
type: firstType,
|
|
363
|
+
value: terms.map(formatLeafValue).join(" OR "),
|
|
364
|
+
operator: "OR"
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
type: "Group",
|
|
369
|
+
value: formatFilterExpr(expr),
|
|
370
|
+
operator: "OR"
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (expr._tag === "And") {
|
|
374
|
+
return {
|
|
375
|
+
type: "Group",
|
|
376
|
+
value: formatFilterExpr(expr),
|
|
377
|
+
operator: "AND"
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
type: expr._tag,
|
|
382
|
+
value: formatLeafValue(expr)
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const clausePhrase = (expr: FilterExpr): string => {
|
|
387
|
+
if (expr._tag === "Not") {
|
|
388
|
+
const base = clausePhrase(expr.expr);
|
|
389
|
+
const normalized = base.trim();
|
|
390
|
+
if (normalized.startsWith("that are ")) {
|
|
391
|
+
return `that are not ${normalized.slice("that are ".length)}`;
|
|
392
|
+
}
|
|
393
|
+
if (normalized.startsWith("that is ")) {
|
|
394
|
+
return `that is not ${normalized.slice("that is ".length)}`;
|
|
395
|
+
}
|
|
396
|
+
if (normalized.startsWith("with ")) {
|
|
397
|
+
return `without ${normalized.slice("with ".length)}`;
|
|
398
|
+
}
|
|
399
|
+
if (normalized.startsWith("containing ")) {
|
|
400
|
+
return `not containing ${normalized.slice("containing ".length)}`;
|
|
401
|
+
}
|
|
402
|
+
if (normalized.startsWith("matching ")) {
|
|
403
|
+
return `not matching ${normalized.slice("matching ".length)}`;
|
|
404
|
+
}
|
|
405
|
+
if (normalized.startsWith("from ")) {
|
|
406
|
+
return `not from ${normalized.slice("from ".length)}`;
|
|
407
|
+
}
|
|
408
|
+
if (normalized.startsWith("in ")) {
|
|
409
|
+
return `not in ${normalized.slice("in ".length)}`;
|
|
410
|
+
}
|
|
411
|
+
return `not ${normalized}`;
|
|
412
|
+
}
|
|
413
|
+
if (expr._tag === "Or") {
|
|
414
|
+
const terms = flattenOr(expr);
|
|
415
|
+
const firstType = terms[0]?._tag;
|
|
416
|
+
const allSame = terms.every((term) => term._tag === firstType);
|
|
417
|
+
if (allSame && firstType) {
|
|
418
|
+
const values = terms.map(formatLeafValue).join(" or ");
|
|
419
|
+
const sample = terms[0]!;
|
|
420
|
+
switch (sample._tag) {
|
|
421
|
+
case "Hashtag":
|
|
422
|
+
return `with hashtags ${values}`;
|
|
423
|
+
case "Author":
|
|
424
|
+
return `from ${values}`;
|
|
425
|
+
default:
|
|
426
|
+
return `matching ${values}`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return `matching ${formatFilterExpr(expr)}`;
|
|
430
|
+
}
|
|
431
|
+
if (expr._tag === "And") {
|
|
432
|
+
return `matching ${formatFilterExpr(expr)}`;
|
|
433
|
+
}
|
|
434
|
+
return formatLeafPhrase(expr);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const summaryFor = (expr: FilterExpr): string => {
|
|
438
|
+
if (expr._tag === "All") return "All posts";
|
|
439
|
+
if (expr._tag === "None") return "No posts";
|
|
440
|
+
const clauses = flattenAnd(expr);
|
|
441
|
+
const phrases = clauses.map(clausePhrase);
|
|
442
|
+
const summary = phrases.reduce((acc, phrase, index) => {
|
|
443
|
+
if (index === 0) return phrase;
|
|
444
|
+
if (phrase.startsWith("that ")) {
|
|
445
|
+
return `${acc} ${phrase}`;
|
|
446
|
+
}
|
|
447
|
+
return `${acc} and ${phrase}`;
|
|
448
|
+
}, "");
|
|
449
|
+
return `Posts ${summary}`;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const describeFilter = (expr: FilterExpr): FilterDescription => {
|
|
453
|
+
const effectful = isEffectfulFilter(expr);
|
|
454
|
+
const conditionCount = countConditions(expr);
|
|
455
|
+
const negationCount = countNegations(expr);
|
|
456
|
+
const complexity = complexityFor(conditionCount, negationCount);
|
|
457
|
+
const estimatedCost = estimatedCostFor(effectful, conditionCount);
|
|
458
|
+
const conditions =
|
|
459
|
+
expr._tag === "All" || expr._tag === "None"
|
|
460
|
+
? []
|
|
461
|
+
: flattenAnd(expr).map(describeClause);
|
|
462
|
+
return {
|
|
463
|
+
filter: formatFilterExpr(expr),
|
|
464
|
+
summary: summaryFor(expr),
|
|
465
|
+
conditions,
|
|
466
|
+
effectful,
|
|
467
|
+
eventTimeCompatible: !effectful,
|
|
468
|
+
deriveTimeCompatible: true,
|
|
469
|
+
complexity,
|
|
470
|
+
conditionCount,
|
|
471
|
+
negationCount,
|
|
472
|
+
estimatedCost
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const conditionLine = (condition: FilterCondition) => {
|
|
477
|
+
const prefix = condition.negated ? "Must NOT " : "Must ";
|
|
478
|
+
switch (condition.type) {
|
|
479
|
+
case "Hashtag":
|
|
480
|
+
return `${prefix}have hashtag: ${condition.value}`;
|
|
481
|
+
case "Author":
|
|
482
|
+
return `${prefix}be from: ${condition.value}`;
|
|
483
|
+
case "AuthorIn":
|
|
484
|
+
return `${prefix}be from one of: ${condition.value}`;
|
|
485
|
+
case "HashtagIn":
|
|
486
|
+
return `${prefix}have hashtag in: ${condition.value}`;
|
|
487
|
+
case "Contains":
|
|
488
|
+
return `${prefix}contain: ${condition.value}`;
|
|
489
|
+
case "IsReply":
|
|
490
|
+
return `${prefix}be a reply`;
|
|
491
|
+
case "IsQuote":
|
|
492
|
+
return `${prefix}be a quote`;
|
|
493
|
+
case "IsRepost":
|
|
494
|
+
return `${prefix}be a repost`;
|
|
495
|
+
case "IsOriginal":
|
|
496
|
+
return `${prefix}be an original post`;
|
|
497
|
+
case "Engagement":
|
|
498
|
+
return `${prefix}meet engagement: ${condition.value}`;
|
|
499
|
+
case "HasImages":
|
|
500
|
+
return `${prefix}include images`;
|
|
501
|
+
case "HasVideo":
|
|
502
|
+
return `${prefix}include video`;
|
|
503
|
+
case "HasLinks":
|
|
504
|
+
return `${prefix}include links`;
|
|
505
|
+
case "HasMedia":
|
|
506
|
+
return `${prefix}include media`;
|
|
507
|
+
case "HasEmbed":
|
|
508
|
+
return `${prefix}include embeds`;
|
|
509
|
+
case "Language":
|
|
510
|
+
return `${prefix}be in: ${condition.value}`;
|
|
511
|
+
case "Regex":
|
|
512
|
+
return `${prefix}match regex: ${condition.value}`;
|
|
513
|
+
case "DateRange":
|
|
514
|
+
return `${prefix}be in date range: ${condition.value}`;
|
|
515
|
+
case "HasValidLinks":
|
|
516
|
+
return `${prefix}have valid links`;
|
|
517
|
+
case "Trending":
|
|
518
|
+
return `${prefix}match trending: ${condition.value}`;
|
|
519
|
+
default:
|
|
520
|
+
return `${prefix}match ${condition.type}: ${condition.value}`;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const titleCase = (value: string) => value.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
525
|
+
|
|
526
|
+
export const renderFilterDescription = (description: FilterDescription): string => {
|
|
527
|
+
const lines: string[] = [];
|
|
528
|
+
lines.push(description.summary);
|
|
529
|
+
|
|
530
|
+
if (description.conditions.length > 0) {
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push("Breakdown:");
|
|
533
|
+
for (const condition of description.conditions) {
|
|
534
|
+
lines.push(`- ${conditionLine(condition)}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
lines.push("");
|
|
539
|
+
lines.push("Mode compatibility:");
|
|
540
|
+
lines.push(`- EventTime: ${description.eventTimeCompatible ? "YES" : "NO"}`);
|
|
541
|
+
lines.push("- DeriveTime: YES");
|
|
542
|
+
|
|
543
|
+
lines.push("");
|
|
544
|
+
lines.push(`Effectful: ${description.effectful ? "Yes" : "No"}`);
|
|
545
|
+
lines.push(`Estimated cost: ${titleCase(description.estimatedCost)}`);
|
|
546
|
+
lines.push(
|
|
547
|
+
`Complexity: ${titleCase(description.complexity)} (${description.conditionCount} conditions, ${description.negationCount} negations)`
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
return lines.join("\n");
|
|
551
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FilterExpr } from "./filter.js";
|
|
2
|
+
|
|
3
|
+
export type FilterExplanation = {
|
|
4
|
+
readonly _tag: FilterExpr["_tag"];
|
|
5
|
+
readonly ok: boolean;
|
|
6
|
+
readonly detail?: string;
|
|
7
|
+
readonly skipped?: boolean;
|
|
8
|
+
readonly children?: ReadonlyArray<FilterExplanation>;
|
|
9
|
+
};
|