@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,205 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli";
|
|
2
|
+
import { Clock, Effect, Option } from "effect";
|
|
3
|
+
import { filterExprSignature, isEffectfulFilter } from "../domain/filter.js";
|
|
4
|
+
import { defaultStoreConfig } from "../domain/defaults.js";
|
|
5
|
+
import { StoreName } from "../domain/primitives.js";
|
|
6
|
+
import { DerivationEngine } from "../services/derivation-engine.js";
|
|
7
|
+
import { StoreManager } from "../services/store-manager.js";
|
|
8
|
+
import { ViewCheckpointStore } from "../services/view-checkpoint-store.js";
|
|
9
|
+
import { OutputManager } from "../services/output-manager.js";
|
|
10
|
+
import { filterJsonDescription } from "./filter-help.js";
|
|
11
|
+
import { parseFilterExpr } from "./filter-input.js";
|
|
12
|
+
import { writeJson } from "./output.js";
|
|
13
|
+
import { storeOptions } from "./store.js";
|
|
14
|
+
import { CliInputError } from "./errors.js";
|
|
15
|
+
import { logInfo } from "./logging.js";
|
|
16
|
+
import type { FilterEvaluationMode } from "../domain/derivation.js";
|
|
17
|
+
import { CliPreferences } from "./preferences.js";
|
|
18
|
+
import { withExamples } from "./help.js";
|
|
19
|
+
import { filterOption } from "./shared-options.js";
|
|
20
|
+
|
|
21
|
+
const sourceArg = Args.text({ name: "source" }).pipe(
|
|
22
|
+
Args.withSchema(StoreName),
|
|
23
|
+
Args.withDescription("Source store name")
|
|
24
|
+
);
|
|
25
|
+
const targetArg = Args.text({ name: "target" }).pipe(
|
|
26
|
+
Args.withSchema(StoreName),
|
|
27
|
+
Args.withDescription("Target (derived) store name")
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const filterJsonOption = Options.text("filter-json").pipe(
|
|
31
|
+
Options.withDescription(
|
|
32
|
+
filterJsonDescription("EventTime mode supports pure filters only.")
|
|
33
|
+
),
|
|
34
|
+
Options.optional
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const modeOption = Options.choice("mode", ["event-time", "derive-time"]).pipe(
|
|
38
|
+
Options.withDescription("Filter evaluation mode (default: event-time)"),
|
|
39
|
+
Options.withDefault("event-time" as const)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const resetFlag = Options.boolean("reset").pipe(
|
|
43
|
+
Options.withDescription("Reset the target store before deriving")
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const yesFlag = Options.boolean("yes").pipe(
|
|
47
|
+
Options.withAlias("y"),
|
|
48
|
+
Options.withDescription("Confirm destructive operations")
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const mapMode = (mode: "event-time" | "derive-time"): FilterEvaluationMode => {
|
|
52
|
+
return mode === "event-time" ? "EventTime" : "DeriveTime";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const deriveCommand = Command.make(
|
|
56
|
+
"derive",
|
|
57
|
+
{
|
|
58
|
+
source: sourceArg,
|
|
59
|
+
target: targetArg,
|
|
60
|
+
filter: filterOption,
|
|
61
|
+
filterJson: filterJsonOption,
|
|
62
|
+
mode: modeOption,
|
|
63
|
+
reset: resetFlag,
|
|
64
|
+
yes: yesFlag
|
|
65
|
+
},
|
|
66
|
+
({ source, target, filter, filterJson, mode, reset, yes }) =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const startTime = yield* Clock.currentTimeMillis;
|
|
69
|
+
const engine = yield* DerivationEngine;
|
|
70
|
+
const checkpoints = yield* ViewCheckpointStore;
|
|
71
|
+
const manager = yield* StoreManager;
|
|
72
|
+
const outputManager = yield* OutputManager;
|
|
73
|
+
const preferences = yield* CliPreferences;
|
|
74
|
+
|
|
75
|
+
// Parse filter expression
|
|
76
|
+
const filterExpr = yield* parseFilterExpr(filter, filterJson);
|
|
77
|
+
|
|
78
|
+
// Validation 1: EventTime mode guard for effectful filters
|
|
79
|
+
// Defense-in-depth: CLI validates for UX (user-friendly errors),
|
|
80
|
+
// service validates for safety (in case called from other contexts)
|
|
81
|
+
const evaluationMode = mapMode(mode);
|
|
82
|
+
if (evaluationMode === "EventTime" && isEffectfulFilter(filterExpr)) {
|
|
83
|
+
return yield* CliInputError.make({
|
|
84
|
+
message:
|
|
85
|
+
"EventTime mode does not allow Trending/HasValidLinks filters. Use --mode derive-time for effectful filters.",
|
|
86
|
+
cause: { filterExpr, mode: evaluationMode }
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validation 2: Reset requires --yes confirmation
|
|
91
|
+
if (reset && !yes) {
|
|
92
|
+
return yield* CliInputError.make({
|
|
93
|
+
message: "--reset is destructive. Re-run with --yes to confirm.",
|
|
94
|
+
cause: { reset: true, yes: false }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validation 3: Source and target must be different
|
|
99
|
+
if (source === target) {
|
|
100
|
+
return yield* CliInputError.make({
|
|
101
|
+
message: "Source and target stores must be different.",
|
|
102
|
+
cause: { source, target }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validation 4: Filter or mode change detection (only if not resetting)
|
|
107
|
+
if (!reset) {
|
|
108
|
+
const checkpointOption = yield* checkpoints.load(target, source);
|
|
109
|
+
if (Option.isSome(checkpointOption)) {
|
|
110
|
+
const checkpoint = checkpointOption.value;
|
|
111
|
+
const newFilterHash = filterExprSignature(filterExpr);
|
|
112
|
+
if (checkpoint.filterHash !== newFilterHash || checkpoint.evaluationMode !== evaluationMode) {
|
|
113
|
+
const message = [
|
|
114
|
+
"Derivation settings have changed since last derivation.",
|
|
115
|
+
`Previous filter hash: ${checkpoint.filterHash}`,
|
|
116
|
+
`New filter hash: ${newFilterHash}`,
|
|
117
|
+
`Previous mode: ${checkpoint.evaluationMode}`,
|
|
118
|
+
`New mode: ${evaluationMode}`,
|
|
119
|
+
"",
|
|
120
|
+
"This would result in inconsistent data. Options:",
|
|
121
|
+
" 1. Use --reset --yes to discard existing data and start fresh",
|
|
122
|
+
" 2. Use the same filter expression as before",
|
|
123
|
+
" 3. Derive into a new target store"
|
|
124
|
+
].join("\n");
|
|
125
|
+
|
|
126
|
+
return yield* CliInputError.make({
|
|
127
|
+
message,
|
|
128
|
+
cause: {
|
|
129
|
+
oldHash: checkpoint.filterHash,
|
|
130
|
+
newHash: newFilterHash,
|
|
131
|
+
oldMode: checkpoint.evaluationMode,
|
|
132
|
+
newMode: evaluationMode
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Load store references
|
|
140
|
+
const sourceRef = yield* storeOptions.loadStoreRef(source);
|
|
141
|
+
const targetOption = yield* manager.getStore(target);
|
|
142
|
+
const targetRef = yield* Option.match(targetOption, {
|
|
143
|
+
onNone: () =>
|
|
144
|
+
manager.createStore(target, defaultStoreConfig).pipe(
|
|
145
|
+
Effect.tap(() => logInfo("Auto-created target store", { target }))
|
|
146
|
+
),
|
|
147
|
+
onSome: Effect.succeed
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Execute derivation
|
|
151
|
+
const result = yield* engine.derive(sourceRef, targetRef, filterExpr, {
|
|
152
|
+
mode: evaluationMode,
|
|
153
|
+
reset
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const materialized = yield* outputManager.materializeStore(targetRef);
|
|
157
|
+
if (materialized.filters.length > 0) {
|
|
158
|
+
yield* logInfo("Materialized filter outputs", {
|
|
159
|
+
store: targetRef.name,
|
|
160
|
+
filters: materialized.filters.map((spec) => spec.name)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Calculate duration and percentage
|
|
165
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
166
|
+
const duration = (endTime - startTime) / 1000;
|
|
167
|
+
const percentage = result.eventsProcessed > 0
|
|
168
|
+
? ((result.eventsMatched / result.eventsProcessed) * 100).toFixed(1)
|
|
169
|
+
: "0.0";
|
|
170
|
+
|
|
171
|
+
// Human-friendly summary (always shown)
|
|
172
|
+
yield* logInfo(
|
|
173
|
+
`Derived ${result.eventsMatched} posts (${percentage}%) from ${sourceRef.name} → ${targetRef.name} in ${duration.toFixed(1)}s`
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Output result with context
|
|
177
|
+
if (preferences.compact) {
|
|
178
|
+
yield* writeJson({
|
|
179
|
+
source: sourceRef.name,
|
|
180
|
+
target: targetRef.name,
|
|
181
|
+
mode: evaluationMode,
|
|
182
|
+
...result
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
yield* writeJson({
|
|
188
|
+
source: sourceRef.name,
|
|
189
|
+
target: targetRef.name,
|
|
190
|
+
mode: evaluationMode,
|
|
191
|
+
result
|
|
192
|
+
});
|
|
193
|
+
})
|
|
194
|
+
).pipe(
|
|
195
|
+
Command.withDescription(
|
|
196
|
+
withExamples(
|
|
197
|
+
"Derive a target store from a source store by applying a filter",
|
|
198
|
+
[
|
|
199
|
+
"skygent derive source-store derived-store --filter 'hashtag:#ai'",
|
|
200
|
+
"skygent derive source-store derived-store --filter 'hashtag:#ai' --mode derive-time"
|
|
201
|
+
],
|
|
202
|
+
["Tip: use --reset --yes if you need to rebuild with a new filter or mode."]
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as Ansi from "@effect/printer-ansi/Ansi";
|
|
2
|
+
|
|
3
|
+
export type Annotation =
|
|
4
|
+
| "label" | "value" | "dim" | "accent" | "muted" | "connector"
|
|
5
|
+
| "error" | "warning" | "metric" | "badge"
|
|
6
|
+
| "storeName" | "storeName:root" | "storeName:derived"
|
|
7
|
+
| "status:ready" | "status:stale" | "status:unknown" | "status:source"
|
|
8
|
+
| "sync:current" | "sync:stale" | "sync:empty" | "sync:unknown"
|
|
9
|
+
| "author" | "hashtag" | "link" | "timestamp" | "embed" | "cycle";
|
|
10
|
+
|
|
11
|
+
export const toAnsi = (a: Annotation): Ansi.Ansi => {
|
|
12
|
+
switch (a) {
|
|
13
|
+
case "label": case "dim": case "muted": case "connector": return Ansi.blackBright;
|
|
14
|
+
case "value": return Ansi.white;
|
|
15
|
+
case "storeName": return Ansi.cyan;
|
|
16
|
+
case "storeName:root": return Ansi.combine(Ansi.cyan, Ansi.bold);
|
|
17
|
+
case "storeName:derived": return Ansi.magenta;
|
|
18
|
+
case "status:ready": return Ansi.green;
|
|
19
|
+
case "status:stale": return Ansi.red;
|
|
20
|
+
case "status:unknown": return Ansi.yellow;
|
|
21
|
+
case "status:source": return Ansi.cyan;
|
|
22
|
+
case "sync:current": return Ansi.green;
|
|
23
|
+
case "sync:stale": case "sync:unknown": return Ansi.yellow;
|
|
24
|
+
case "sync:empty": return Ansi.blackBright;
|
|
25
|
+
case "accent": return Ansi.cyan;
|
|
26
|
+
case "error": return Ansi.red;
|
|
27
|
+
case "warning": case "cycle": return Ansi.yellow;
|
|
28
|
+
case "metric": return Ansi.whiteBright;
|
|
29
|
+
case "badge": return Ansi.bold;
|
|
30
|
+
case "author": return Ansi.combine(Ansi.cyan, Ansi.bold);
|
|
31
|
+
case "hashtag": return Ansi.blue;
|
|
32
|
+
case "link": return Ansi.underlined;
|
|
33
|
+
case "timestamp": return Ansi.blackBright;
|
|
34
|
+
case "embed": return Ansi.magenta;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import type { Annotation } from "./annotation.js";
|
|
3
|
+
import { ann, field } from "./primitives.js";
|
|
4
|
+
import type { FilterCondition, FilterDescription } from "../../domain/filter-describe.js";
|
|
5
|
+
|
|
6
|
+
type SDoc = Doc.Doc<Annotation>;
|
|
7
|
+
|
|
8
|
+
const titleCase = (value: string) => value.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
9
|
+
|
|
10
|
+
const conditionLine = (condition: FilterCondition): string => {
|
|
11
|
+
const prefix = condition.negated ? "Must NOT " : "Must ";
|
|
12
|
+
switch (condition.type) {
|
|
13
|
+
case "Hashtag": return `${prefix}have hashtag: ${condition.value}`;
|
|
14
|
+
case "Author": return `${prefix}be from: ${condition.value}`;
|
|
15
|
+
case "AuthorIn": return `${prefix}be from one of: ${condition.value}`;
|
|
16
|
+
case "HashtagIn": return `${prefix}have hashtag in: ${condition.value}`;
|
|
17
|
+
case "Contains": return `${prefix}contain: ${condition.value}`;
|
|
18
|
+
case "IsReply": return `${prefix}be a reply`;
|
|
19
|
+
case "IsQuote": return `${prefix}be a quote`;
|
|
20
|
+
case "IsRepost": return `${prefix}be a repost`;
|
|
21
|
+
case "IsOriginal": return `${prefix}be an original post`;
|
|
22
|
+
case "Engagement": return `${prefix}meet engagement: ${condition.value}`;
|
|
23
|
+
case "HasImages": return `${prefix}include images`;
|
|
24
|
+
case "HasVideo": return `${prefix}include video`;
|
|
25
|
+
case "HasLinks": return `${prefix}include links`;
|
|
26
|
+
case "HasMedia": return `${prefix}include media`;
|
|
27
|
+
case "HasEmbed": return `${prefix}include embeds`;
|
|
28
|
+
case "Language": return `${prefix}be in: ${condition.value}`;
|
|
29
|
+
case "Regex": return `${prefix}match regex: ${condition.value}`;
|
|
30
|
+
case "DateRange": return `${prefix}be in date range: ${condition.value}`;
|
|
31
|
+
case "HasValidLinks": return `${prefix}have valid links`;
|
|
32
|
+
case "Trending": return `${prefix}match trending: ${condition.value}`;
|
|
33
|
+
default: return `${prefix}match ${condition.type}: ${condition.value}`;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const renderFilterDescriptionDoc = (description: FilterDescription): SDoc => {
|
|
38
|
+
const lines: SDoc[] = [];
|
|
39
|
+
lines.push(ann("value", Doc.text(description.summary)));
|
|
40
|
+
|
|
41
|
+
if (description.conditions.length > 0) {
|
|
42
|
+
lines.push(Doc.empty);
|
|
43
|
+
lines.push(ann("label", Doc.text("Breakdown:")));
|
|
44
|
+
for (const condition of description.conditions) {
|
|
45
|
+
lines.push(Doc.hsep([ann("dim", Doc.text("-")), Doc.text(conditionLine(condition))]));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
lines.push(Doc.empty);
|
|
50
|
+
lines.push(ann("label", Doc.text("Mode compatibility:")));
|
|
51
|
+
lines.push(Doc.hsep([
|
|
52
|
+
ann("dim", Doc.text("-")),
|
|
53
|
+
Doc.text("EventTime:"),
|
|
54
|
+
ann(description.eventTimeCompatible ? "status:ready" : "status:stale",
|
|
55
|
+
Doc.text(description.eventTimeCompatible ? "YES" : "NO"))
|
|
56
|
+
]));
|
|
57
|
+
lines.push(Doc.hsep([
|
|
58
|
+
ann("dim", Doc.text("-")),
|
|
59
|
+
Doc.text("DeriveTime:"),
|
|
60
|
+
ann("status:ready", Doc.text("YES"))
|
|
61
|
+
]));
|
|
62
|
+
|
|
63
|
+
lines.push(Doc.empty);
|
|
64
|
+
lines.push(field("Effectful", description.effectful ? "Yes" : "No"));
|
|
65
|
+
lines.push(field("Estimated cost", titleCase(description.estimatedCost)));
|
|
66
|
+
lines.push(field("Complexity", `${titleCase(description.complexity)} (${description.conditionCount} conditions, ${description.negationCount} negations)`));
|
|
67
|
+
|
|
68
|
+
return Doc.vsep(lines);
|
|
69
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { Annotation } from "./annotation.js";
|
|
2
|
+
export { toAnsi } from "./annotation.js";
|
|
3
|
+
export { renderPlain, renderAnsi } from "./render.js";
|
|
4
|
+
export { ann, label, value, field, badge, connector, metric } from "./primitives.js";
|
|
5
|
+
export type { RenderContext, TreeConfig } from "./tree.js";
|
|
6
|
+
export { renderTree } from "./tree.js";
|
|
7
|
+
export { renderPostCompact, renderPostCard } from "./post.js";
|
|
8
|
+
export { renderThread } from "./thread.js";
|
|
9
|
+
export { renderFilterDescriptionDoc } from "./filter.js";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import type { Annotation } from "./annotation.js";
|
|
3
|
+
import { ann, metric } from "./primitives.js";
|
|
4
|
+
import { collapseWhitespace, normalizeWhitespace, truncate } from "../../domain/format.js";
|
|
5
|
+
import type { Post } from "../../domain/post.js";
|
|
6
|
+
import type { PostEmbed } from "../../domain/bsky.js";
|
|
7
|
+
|
|
8
|
+
type SDoc = Doc.Doc<Annotation>;
|
|
9
|
+
|
|
10
|
+
const renderEmbedSummary = (embed: PostEmbed): SDoc => {
|
|
11
|
+
switch (embed._tag) {
|
|
12
|
+
case "Images": return Doc.text(`[Images: ${embed.images.length}]`);
|
|
13
|
+
case "External": return Doc.text(`[Link: ${truncate(embed.title || embed.uri, 40)}]`);
|
|
14
|
+
case "Video": return Doc.text("[Video]");
|
|
15
|
+
case "Record":
|
|
16
|
+
return Doc.text(
|
|
17
|
+
embed.record._tag === "RecordView"
|
|
18
|
+
? `[Quote: @${embed.record.author.handle}]`
|
|
19
|
+
: "[Quote]"
|
|
20
|
+
);
|
|
21
|
+
case "RecordWithMedia":
|
|
22
|
+
return Doc.text(
|
|
23
|
+
embed.record._tag === "RecordView"
|
|
24
|
+
? `[Quote: @${embed.record.author.handle} + media]`
|
|
25
|
+
: "[Quote + media]"
|
|
26
|
+
);
|
|
27
|
+
default: return Doc.text("[Embed]");
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const wrapText = (text: string, maxWidth?: number): ReadonlyArray<string> => {
|
|
32
|
+
const normalized = normalizeWhitespace(text);
|
|
33
|
+
if (!maxWidth || maxWidth <= 0) {
|
|
34
|
+
const lines = normalized.split("\n");
|
|
35
|
+
return lines.length > 0 ? lines : [normalized];
|
|
36
|
+
}
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
const paragraphs = normalized.split("\n");
|
|
39
|
+
for (const paragraph of paragraphs) {
|
|
40
|
+
if (paragraph.length === 0) {
|
|
41
|
+
lines.push("");
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const words = paragraph.split(" ");
|
|
45
|
+
let current = "";
|
|
46
|
+
const flushCurrent = () => {
|
|
47
|
+
if (current.length > 0) {
|
|
48
|
+
lines.push(current);
|
|
49
|
+
current = "";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
for (let word of words) {
|
|
53
|
+
while (word.length > maxWidth && maxWidth > 1) {
|
|
54
|
+
const chunk = word.slice(0, maxWidth - 1);
|
|
55
|
+
flushCurrent();
|
|
56
|
+
lines.push(`${chunk}-`);
|
|
57
|
+
word = word.slice(maxWidth - 1);
|
|
58
|
+
}
|
|
59
|
+
if (current.length === 0) {
|
|
60
|
+
current = word;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (current.length + 1 + word.length <= maxWidth) {
|
|
64
|
+
current = `${current} ${word}`;
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(current);
|
|
67
|
+
current = word;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
flushCurrent();
|
|
71
|
+
}
|
|
72
|
+
return lines.length > 0 ? lines : [normalized];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const renderPostCompact = (post: Post): SDoc => {
|
|
76
|
+
const text = post.text ?? "";
|
|
77
|
+
const parts: SDoc[] = [
|
|
78
|
+
ann("author", Doc.text(`@${post.author}`)),
|
|
79
|
+
ann("dim", Doc.text("·")),
|
|
80
|
+
ann("timestamp", Doc.text(post.createdAt.toISOString().slice(0, 10))),
|
|
81
|
+
Doc.text(truncate(collapseWhitespace(text), 60))
|
|
82
|
+
];
|
|
83
|
+
if (post.metrics) {
|
|
84
|
+
const m = post.metrics;
|
|
85
|
+
if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
|
|
86
|
+
if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
|
|
87
|
+
if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
|
|
88
|
+
}
|
|
89
|
+
return Doc.hsep(parts);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Returns an array of Doc lines suitable for multi-line tree rendering.
|
|
93
|
+
* When used standalone, combine with `Doc.vsep(renderPostCard(post))`. */
|
|
94
|
+
export const renderPostCard = (post: Post): ReadonlyArray<SDoc> => {
|
|
95
|
+
const text = post.text ?? "";
|
|
96
|
+
const lines: SDoc[] = [];
|
|
97
|
+
|
|
98
|
+
lines.push(Doc.hsep([
|
|
99
|
+
ann("author", Doc.text(`@${post.author}`)),
|
|
100
|
+
ann("dim", Doc.text("·")),
|
|
101
|
+
ann("timestamp", Doc.text(post.createdAt.toISOString()))
|
|
102
|
+
]));
|
|
103
|
+
|
|
104
|
+
const paragraphs = normalizeWhitespace(text).split("\n");
|
|
105
|
+
lines.push(Doc.vsep(paragraphs.map((paragraph) => Doc.reflow(paragraph))));
|
|
106
|
+
|
|
107
|
+
if (post.embed) lines.push(ann("embed", renderEmbedSummary(post.embed)));
|
|
108
|
+
|
|
109
|
+
if (post.metrics) {
|
|
110
|
+
const parts: SDoc[] = [];
|
|
111
|
+
const m = post.metrics;
|
|
112
|
+
if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
|
|
113
|
+
if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
|
|
114
|
+
if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
|
|
115
|
+
if (m.quoteCount != null && m.quoteCount > 0) parts.push(metric("❝", m.quoteCount));
|
|
116
|
+
if (parts.length > 0) lines.push(Doc.hsep(parts));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return lines;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const renderPostCardLines = (
|
|
123
|
+
post: Post,
|
|
124
|
+
options?: { lineWidth?: number }
|
|
125
|
+
): ReadonlyArray<SDoc> => {
|
|
126
|
+
const text = post.text ?? "";
|
|
127
|
+
const lines: SDoc[] = [];
|
|
128
|
+
|
|
129
|
+
lines.push(Doc.hsep([
|
|
130
|
+
ann("author", Doc.text(`@${post.author}`)),
|
|
131
|
+
ann("dim", Doc.text("·")),
|
|
132
|
+
ann("timestamp", Doc.text(post.createdAt.toISOString()))
|
|
133
|
+
]));
|
|
134
|
+
|
|
135
|
+
const textLines = wrapText(text, options?.lineWidth);
|
|
136
|
+
for (const line of textLines) {
|
|
137
|
+
lines.push(Doc.text(line));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (post.embed) {
|
|
141
|
+
lines.push(ann("embed", renderEmbedSummary(post.embed)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (post.metrics) {
|
|
145
|
+
const parts: SDoc[] = [];
|
|
146
|
+
const m = post.metrics;
|
|
147
|
+
if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
|
|
148
|
+
if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
|
|
149
|
+
if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
|
|
150
|
+
if (m.quoteCount != null && m.quoteCount > 0) parts.push(metric("❝", m.quoteCount));
|
|
151
|
+
if (parts.length > 0) lines.push(Doc.hsep(parts));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return lines;
|
|
155
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import { pipe } from "effect/Function";
|
|
3
|
+
import type { Annotation } from "./annotation.js";
|
|
4
|
+
|
|
5
|
+
type SDoc = Doc.Doc<Annotation>;
|
|
6
|
+
|
|
7
|
+
export const ann = (a: Annotation, doc: SDoc): SDoc => Doc.annotate(doc, a);
|
|
8
|
+
|
|
9
|
+
export const label = (text: string): SDoc => ann("label", Doc.text(text));
|
|
10
|
+
export const value = (text: string): SDoc => ann("value", Doc.text(text));
|
|
11
|
+
|
|
12
|
+
export const field = (key: string, val: string, keyWidth?: number): SDoc =>
|
|
13
|
+
Doc.hsep([
|
|
14
|
+
keyWidth ? pipe(label(key + ":"), Doc.fillBreak(keyWidth)) : label(key + ":"),
|
|
15
|
+
value(val)
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const badge = (text: string, annotation: Annotation): SDoc =>
|
|
19
|
+
ann(annotation, Doc.text(`[${text}]`));
|
|
20
|
+
|
|
21
|
+
export const connector = (text: string): SDoc =>
|
|
22
|
+
ann("connector", Doc.text(text));
|
|
23
|
+
|
|
24
|
+
export const metric = (icon: string, count: number): SDoc =>
|
|
25
|
+
ann("metric", Doc.text(`${icon} ${new Intl.NumberFormat("en-US").format(count)}`));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import * as AnsiDoc from "@effect/printer-ansi/AnsiDoc";
|
|
3
|
+
import type { Annotation } from "./annotation.js";
|
|
4
|
+
import { toAnsi } from "./annotation.js";
|
|
5
|
+
|
|
6
|
+
export const renderPlain = (doc: Doc.Doc<Annotation>, width?: number): string => {
|
|
7
|
+
const plain = Doc.unAnnotate(doc);
|
|
8
|
+
return width
|
|
9
|
+
? Doc.render(plain, { style: "pretty", options: { lineWidth: width } })
|
|
10
|
+
: Doc.render(plain, { style: "pretty" });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const renderAnsi = (doc: Doc.Doc<Annotation>, width?: number): string => {
|
|
14
|
+
const ansiDoc = Doc.reAnnotate(doc, toAnsi);
|
|
15
|
+
return width
|
|
16
|
+
? AnsiDoc.render(ansiDoc, { style: "pretty", options: { lineWidth: width } })
|
|
17
|
+
: AnsiDoc.render(ansiDoc, { style: "pretty" });
|
|
18
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import { pipe } from "effect/Function";
|
|
3
|
+
import type { Annotation } from "./annotation.js";
|
|
4
|
+
import { renderAnsi, renderPlain } from "./render.js";
|
|
5
|
+
import { ann, label, value } from "./primitives.js";
|
|
6
|
+
import { displayWidth } from "../../domain/text-width.js";
|
|
7
|
+
|
|
8
|
+
export type SDoc = Doc.Doc<Annotation>;
|
|
9
|
+
|
|
10
|
+
export interface TableColumn {
|
|
11
|
+
readonly header: string;
|
|
12
|
+
readonly width?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TableConfig {
|
|
16
|
+
readonly columns: ReadonlyArray<TableColumn>;
|
|
17
|
+
readonly rows: ReadonlyArray<ReadonlyArray<string>>;
|
|
18
|
+
readonly trimEnd?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const calculateColumnWidths = (
|
|
22
|
+
columns: ReadonlyArray<TableColumn>,
|
|
23
|
+
rows: ReadonlyArray<ReadonlyArray<string>>
|
|
24
|
+
): ReadonlyArray<number> => {
|
|
25
|
+
return columns.map((column, index) => {
|
|
26
|
+
const contentWidths = rows.map((row) => displayWidth(row[index] ?? ""));
|
|
27
|
+
return Math.max(displayWidth(column.header), ...contentWidths);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const buildCell = (
|
|
32
|
+
content: string,
|
|
33
|
+
width: number,
|
|
34
|
+
style?: "label" | "value" | "dim"
|
|
35
|
+
): SDoc => {
|
|
36
|
+
const doc = style === "label"
|
|
37
|
+
? label(content)
|
|
38
|
+
: style === "dim"
|
|
39
|
+
? ann("dim", Doc.text(content))
|
|
40
|
+
: value(content);
|
|
41
|
+
|
|
42
|
+
return pipe(doc, Doc.fillBreak(width));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const buildRow = (
|
|
46
|
+
cells: ReadonlyArray<string>,
|
|
47
|
+
widths: ReadonlyArray<number>,
|
|
48
|
+
styles?: ReadonlyArray<"label" | "value" | "dim" | undefined>,
|
|
49
|
+
trimEnd = false
|
|
50
|
+
): SDoc => {
|
|
51
|
+
const cellDocs = cells.map((cell, i) => {
|
|
52
|
+
const style = styles?.[i];
|
|
53
|
+
return buildCell(cell, widths[i] ?? 0, style);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const row = Doc.hsep(cellDocs);
|
|
57
|
+
|
|
58
|
+
if (trimEnd) {
|
|
59
|
+
return pipe(
|
|
60
|
+
row,
|
|
61
|
+
Doc.render({ style: "pretty" }),
|
|
62
|
+
(str: string) => str.trimEnd(),
|
|
63
|
+
Doc.text
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return row;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const buildTableDoc = (config: TableConfig): SDoc => {
|
|
71
|
+
const { columns, rows, trimEnd = false } = config;
|
|
72
|
+
|
|
73
|
+
const widths = calculateColumnWidths(columns, rows);
|
|
74
|
+
|
|
75
|
+
const headerCells = columns.map((col, i) =>
|
|
76
|
+
buildCell(col.header, widths[i] ?? 0, "label")
|
|
77
|
+
);
|
|
78
|
+
const header = Doc.hsep(headerCells);
|
|
79
|
+
|
|
80
|
+
const separatorCells = widths.map((w) => ann("dim", Doc.text("-".repeat(w))));
|
|
81
|
+
const separator = Doc.hsep(separatorCells);
|
|
82
|
+
|
|
83
|
+
const dataRows = rows.map((row) => {
|
|
84
|
+
const dataCells = row.map((cell, i) => buildCell(cell, widths[i] ?? 0, "value"));
|
|
85
|
+
const dataRow = Doc.hsep(dataCells);
|
|
86
|
+
|
|
87
|
+
if (trimEnd) {
|
|
88
|
+
return pipe(
|
|
89
|
+
dataRow,
|
|
90
|
+
Doc.render({ style: "pretty" }),
|
|
91
|
+
(str: string) => str.trimEnd(),
|
|
92
|
+
Doc.text
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return dataRow;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return Doc.vsep([header, separator, ...dataRows]);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const renderTable = (config: TableConfig, ansi = false): string => {
|
|
103
|
+
const doc = buildTableDoc(config);
|
|
104
|
+
return ansi ? renderAnsi(doc) : renderPlain(doc);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const renderTableLegacy = (
|
|
108
|
+
headers: ReadonlyArray<string>,
|
|
109
|
+
rows: ReadonlyArray<ReadonlyArray<string>>,
|
|
110
|
+
trimEnd = true
|
|
111
|
+
): string => {
|
|
112
|
+
const columns = headers.map((header) => ({ header }));
|
|
113
|
+
return renderTable({ columns, rows, trimEnd });
|
|
114
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as Doc from "@effect/printer/Doc";
|
|
2
|
+
import type { Annotation } from "./annotation.js";
|
|
3
|
+
import { renderTree } from "./tree.js";
|
|
4
|
+
import { renderPostCompact, renderPostCardLines } from "./post.js";
|
|
5
|
+
import type { Post } from "../../domain/post.js";
|
|
6
|
+
|
|
7
|
+
export const renderThread = (
|
|
8
|
+
posts: ReadonlyArray<Post>,
|
|
9
|
+
options?: { compact?: boolean; lineWidth?: number }
|
|
10
|
+
): Doc.Doc<Annotation> => {
|
|
11
|
+
const byUri = new Map(posts.map((p) => [String(p.uri), p]));
|
|
12
|
+
const childMap = new Map<string, Post[]>();
|
|
13
|
+
const roots: Post[] = [];
|
|
14
|
+
|
|
15
|
+
for (const post of posts) {
|
|
16
|
+
const parentUri = post.reply?.parent.uri ? String(post.reply.parent.uri) : undefined;
|
|
17
|
+
if (parentUri && byUri.has(parentUri)) {
|
|
18
|
+
const siblings = childMap.get(parentUri) ?? [];
|
|
19
|
+
siblings.push(post);
|
|
20
|
+
childMap.set(parentUri, siblings);
|
|
21
|
+
} else {
|
|
22
|
+
roots.push(post);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sortPosts = (arr: Post[]) =>
|
|
27
|
+
arr.sort((a, b) =>
|
|
28
|
+
a.createdAt.getTime() - b.createdAt.getTime() || a.uri.localeCompare(b.uri)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
sortPosts(roots);
|
|
32
|
+
for (const children of childMap.values()) sortPosts(children);
|
|
33
|
+
|
|
34
|
+
const cardOptions =
|
|
35
|
+
options?.lineWidth === undefined ? undefined : { lineWidth: options.lineWidth };
|
|
36
|
+
const render = options?.compact
|
|
37
|
+
? renderPostCompact
|
|
38
|
+
: (post: Post) => renderPostCardLines(post, cardOptions);
|
|
39
|
+
|
|
40
|
+
return renderTree<Post, undefined>(roots, {
|
|
41
|
+
children: (post) =>
|
|
42
|
+
(childMap.get(String(post.uri)) ?? []).map((p) => ({ node: p, edge: undefined })),
|
|
43
|
+
renderNode: (post) => render(post),
|
|
44
|
+
key: (post) => String(post.uri),
|
|
45
|
+
});
|
|
46
|
+
};
|