birdclaw 0.5.0 → 0.6.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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
4
|
+
import type {
|
|
5
|
+
DmConversationItem,
|
|
6
|
+
DmMessageItem,
|
|
7
|
+
QueryEnvelope,
|
|
8
|
+
QueryResponse,
|
|
9
|
+
TimelineItem,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import type {
|
|
12
|
+
WebSyncJobSnapshot,
|
|
13
|
+
WebSyncKind,
|
|
14
|
+
WebSyncOptions,
|
|
15
|
+
WebSyncResponse,
|
|
16
|
+
} from "./web-sync";
|
|
17
|
+
|
|
18
|
+
const jsonRecordSchema = z.object({}).passthrough();
|
|
19
|
+
const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
|
|
20
|
+
const webSyncKindSchema = z.enum([
|
|
21
|
+
"timeline",
|
|
22
|
+
"mentions",
|
|
23
|
+
"likes",
|
|
24
|
+
"bookmarks",
|
|
25
|
+
"dms",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const queryEnvelopeSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
accounts: z.array(jsonRecordSchema),
|
|
31
|
+
archives: z.array(jsonRecordSchema),
|
|
32
|
+
transport: z
|
|
33
|
+
.object({
|
|
34
|
+
statusText: z.string(),
|
|
35
|
+
})
|
|
36
|
+
.passthrough(),
|
|
37
|
+
stats: z.object({
|
|
38
|
+
home: z.number(),
|
|
39
|
+
mentions: z.number(),
|
|
40
|
+
dms: z.number(),
|
|
41
|
+
needsReply: z.number(),
|
|
42
|
+
inbox: z.number(),
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
.transform((value) => value as unknown as QueryEnvelope);
|
|
46
|
+
|
|
47
|
+
const queryResponseSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
resource: resourceKindSchema,
|
|
50
|
+
items: z.array(jsonRecordSchema),
|
|
51
|
+
selectedConversation: z
|
|
52
|
+
.object({
|
|
53
|
+
conversation: jsonRecordSchema,
|
|
54
|
+
messages: z.array(jsonRecordSchema),
|
|
55
|
+
})
|
|
56
|
+
.nullish(),
|
|
57
|
+
})
|
|
58
|
+
.transform(
|
|
59
|
+
(value) =>
|
|
60
|
+
({
|
|
61
|
+
...value,
|
|
62
|
+
items: value.items as unknown as Array<
|
|
63
|
+
TimelineItem | DmConversationItem
|
|
64
|
+
>,
|
|
65
|
+
selectedConversation: value.selectedConversation
|
|
66
|
+
? {
|
|
67
|
+
conversation: value.selectedConversation
|
|
68
|
+
.conversation as unknown as DmConversationItem,
|
|
69
|
+
messages: value.selectedConversation
|
|
70
|
+
.messages as unknown as DmMessageItem[],
|
|
71
|
+
}
|
|
72
|
+
: value.selectedConversation,
|
|
73
|
+
}) as QueryResponse,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const webSyncResponseSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
ok: z.boolean(),
|
|
79
|
+
kind: webSyncKindSchema,
|
|
80
|
+
accountId: z.string().optional(),
|
|
81
|
+
summary: z.string(),
|
|
82
|
+
steps: z.array(jsonRecordSchema),
|
|
83
|
+
startedAt: z.string().optional(),
|
|
84
|
+
finishedAt: z.string().optional(),
|
|
85
|
+
inProgress: z.boolean().optional(),
|
|
86
|
+
backup: z.unknown().optional(),
|
|
87
|
+
error: z.string().optional(),
|
|
88
|
+
})
|
|
89
|
+
.transform((value) => value as unknown as WebSyncResponse);
|
|
90
|
+
|
|
91
|
+
const webSyncJobSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
id: z.string(),
|
|
94
|
+
kind: webSyncKindSchema,
|
|
95
|
+
accountId: z.string().optional(),
|
|
96
|
+
status: z.enum(["running", "succeeded", "failed"]),
|
|
97
|
+
startedAt: z.string(),
|
|
98
|
+
finishedAt: z.string().optional(),
|
|
99
|
+
summary: z.string(),
|
|
100
|
+
inProgress: z.boolean(),
|
|
101
|
+
result: webSyncResponseSchema.optional(),
|
|
102
|
+
error: z.string().optional(),
|
|
103
|
+
})
|
|
104
|
+
.transform((value) => value as unknown as WebSyncJobSnapshot);
|
|
105
|
+
|
|
106
|
+
const actionResponseSchema = jsonRecordSchema;
|
|
107
|
+
const SYNC_POLL_INTERVAL_MS = 500;
|
|
108
|
+
|
|
109
|
+
export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
|
|
110
|
+
readonly message: string;
|
|
111
|
+
readonly status?: number;
|
|
112
|
+
readonly cause?: unknown;
|
|
113
|
+
}> {}
|
|
114
|
+
|
|
115
|
+
function responseMessage(data: unknown, fallback: string) {
|
|
116
|
+
if (data && typeof data === "object") {
|
|
117
|
+
const record = data as {
|
|
118
|
+
message?: unknown;
|
|
119
|
+
error?: unknown;
|
|
120
|
+
summary?: unknown;
|
|
121
|
+
};
|
|
122
|
+
if (typeof record.message === "string") return record.message;
|
|
123
|
+
if (typeof record.error === "string") return record.error;
|
|
124
|
+
if (typeof record.summary === "string") return record.summary;
|
|
125
|
+
}
|
|
126
|
+
return fallback;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function apiFetchErrorFromCause(cause: unknown, fallbackMessage: string) {
|
|
130
|
+
if (cause instanceof DOMException && cause.name === "AbortError") {
|
|
131
|
+
return cause;
|
|
132
|
+
}
|
|
133
|
+
if (cause instanceof ApiFetchError) return cause;
|
|
134
|
+
if (cause instanceof Error) {
|
|
135
|
+
return new ApiFetchError({ message: cause.message, cause });
|
|
136
|
+
}
|
|
137
|
+
if (typeof cause === "string") {
|
|
138
|
+
return new ApiFetchError({ message: cause, cause });
|
|
139
|
+
}
|
|
140
|
+
return new ApiFetchError({ message: fallbackMessage, cause });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readJsonEffect(response: Response) {
|
|
144
|
+
return Effect.promise(() => response.json().catch(() => null as unknown));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
|
|
148
|
+
return runEffectPromise(effect);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function fetchJsonEffect<T>(
|
|
152
|
+
input: RequestInfo | URL,
|
|
153
|
+
init: RequestInit | undefined,
|
|
154
|
+
schema: z.ZodType<T>,
|
|
155
|
+
fallbackMessage: string,
|
|
156
|
+
) {
|
|
157
|
+
return Effect.gen(function* () {
|
|
158
|
+
const response = yield* Effect.tryPromise({
|
|
159
|
+
try: () => fetch(input, init),
|
|
160
|
+
catch: (cause) => apiFetchErrorFromCause(cause, fallbackMessage),
|
|
161
|
+
});
|
|
162
|
+
const data = yield* readJsonEffect(response);
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
return yield* Effect.fail(
|
|
165
|
+
new ApiFetchError({
|
|
166
|
+
message: responseMessage(data, fallbackMessage),
|
|
167
|
+
status: response.status,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const parsed = schema.safeParse(data);
|
|
173
|
+
if (!parsed.success) {
|
|
174
|
+
return yield* Effect.fail(
|
|
175
|
+
new ApiFetchError({
|
|
176
|
+
message: fallbackMessage,
|
|
177
|
+
cause: parsed.error,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return parsed.data;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function fetchJson<T>(
|
|
186
|
+
input: RequestInfo | URL,
|
|
187
|
+
init: RequestInit | undefined,
|
|
188
|
+
schema: z.ZodType<T>,
|
|
189
|
+
fallbackMessage: string,
|
|
190
|
+
): Promise<T> {
|
|
191
|
+
return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function fetchQueryEnvelope(init?: RequestInit) {
|
|
195
|
+
return runApiEffect(fetchQueryEnvelopeEffect(init));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function fetchQueryEnvelopeEffect(init?: RequestInit) {
|
|
199
|
+
return fetchJsonEffect(
|
|
200
|
+
"/api/status",
|
|
201
|
+
init,
|
|
202
|
+
queryEnvelopeSchema,
|
|
203
|
+
"Status unavailable",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function fetchQueryResponse(
|
|
208
|
+
input: RequestInfo | URL,
|
|
209
|
+
init?: RequestInit,
|
|
210
|
+
) {
|
|
211
|
+
return runApiEffect(fetchQueryResponseEffect(input, init));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function fetchQueryResponseEffect(
|
|
215
|
+
input: RequestInfo | URL,
|
|
216
|
+
init?: RequestInit,
|
|
217
|
+
) {
|
|
218
|
+
return fetchJsonEffect(input, init, queryResponseSchema, "Query unavailable");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function postAction(body: Record<string, unknown>) {
|
|
222
|
+
return runApiEffect(postActionEffect(body));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function postActionEffect(body: Record<string, unknown>) {
|
|
226
|
+
return fetchJsonEffect(
|
|
227
|
+
"/api/action",
|
|
228
|
+
{
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify(body),
|
|
232
|
+
},
|
|
233
|
+
actionResponseSchema,
|
|
234
|
+
"Action failed",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function postSync(
|
|
239
|
+
kind: WebSyncKind,
|
|
240
|
+
accountId?: string,
|
|
241
|
+
options: WebSyncOptions = {},
|
|
242
|
+
) {
|
|
243
|
+
return runApiEffect(postSyncEffect(kind, accountId, options));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function postSyncEffect(
|
|
247
|
+
kind: WebSyncKind,
|
|
248
|
+
accountId?: string,
|
|
249
|
+
options: WebSyncOptions = {},
|
|
250
|
+
) {
|
|
251
|
+
return fetchJsonEffect(
|
|
252
|
+
"/api/sync",
|
|
253
|
+
{
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "content-type": "application/json" },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
kind,
|
|
258
|
+
...(accountId ? { accountId } : {}),
|
|
259
|
+
...options,
|
|
260
|
+
}),
|
|
261
|
+
},
|
|
262
|
+
webSyncJobSchema,
|
|
263
|
+
"Sync failed",
|
|
264
|
+
).pipe(Effect.flatMap(waitForWebSyncJobEffect));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function fetchSyncJobEffect(id: string) {
|
|
268
|
+
const url = new URL("/api/sync", window.location.origin);
|
|
269
|
+
url.searchParams.set("id", id);
|
|
270
|
+
return fetchJsonEffect(
|
|
271
|
+
url,
|
|
272
|
+
undefined,
|
|
273
|
+
webSyncJobSchema,
|
|
274
|
+
"Sync status unavailable",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function waitForWebSyncJobEffect(job: WebSyncJobSnapshot) {
|
|
279
|
+
return Effect.gen(function* () {
|
|
280
|
+
let current = job;
|
|
281
|
+
while (current.inProgress) {
|
|
282
|
+
yield* Effect.sleep(SYNC_POLL_INTERVAL_MS);
|
|
283
|
+
current = yield* fetchSyncJobEffect(current.id);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!current.result) {
|
|
287
|
+
return yield* Effect.fail(
|
|
288
|
+
new ApiFetchError({ message: current.error ?? current.summary }),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (!current.result.ok) {
|
|
292
|
+
return yield* Effect.fail(
|
|
293
|
+
new ApiFetchError({
|
|
294
|
+
message: current.result.error ?? current.result.summary,
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return current.result;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function waitForWebSyncJob(job: WebSyncJobSnapshot) {
|
|
303
|
+
return runApiEffect(waitForWebSyncJobEffect(job));
|
|
304
|
+
}
|
|
@@ -3,6 +3,8 @@ import { existsSync, promises as fs } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
6
8
|
import type { ArchiveCandidate } from "./types";
|
|
7
9
|
|
|
8
10
|
const execAsync = promisify(exec);
|
|
@@ -37,11 +39,9 @@ function formatRelativeDate(date: Date): string {
|
|
|
37
39
|
return `${Math.floor(days / 365)} years ago`;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const stats = await fs.stat(filePath);
|
|
42
|
+
function getCandidateEffect(filePath: string) {
|
|
43
|
+
return Effect.gen(function* () {
|
|
44
|
+
const stats = yield* tryPromise(() => fs.stat(filePath));
|
|
45
45
|
if (!stats.isFile() || stats.size < 1024 * 1024) {
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
@@ -54,75 +54,94 @@ async function getCandidate(
|
|
|
54
54
|
modifiedTime: stats.mtime.toISOString(),
|
|
55
55
|
dateFormatted: formatRelativeDate(stats.mtime),
|
|
56
56
|
};
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
57
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)));
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
directoryPath: string,
|
|
64
|
-
): Promise<ArchiveCandidate[]> {
|
|
60
|
+
function searchDirectoryEffect(directoryPath: string) {
|
|
65
61
|
if (!existsSync(directoryPath)) {
|
|
66
|
-
return [];
|
|
62
|
+
return Effect.succeed([]);
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
return Effect.gen(function* () {
|
|
66
|
+
const entries = yield* tryPromise(() => fs.readdir(directoryPath));
|
|
67
|
+
const matches = entries.filter((entry) =>
|
|
68
|
+
ARCHIVE_NAME_PATTERNS.some((pattern) => pattern.test(entry)),
|
|
69
|
+
);
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
const candidates = yield* Effect.forEach(
|
|
72
|
+
matches,
|
|
73
|
+
(entry) => getCandidateEffect(path.join(directoryPath, entry)),
|
|
74
|
+
{ concurrency: "unbounded" },
|
|
75
|
+
);
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
return candidates.filter((item) => item !== null);
|
|
78
|
+
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
function searchSpotlightEffect(query: string) {
|
|
82
|
+
return Effect.gen(function* () {
|
|
83
|
+
const { stdout } = yield* tryPromise(() =>
|
|
84
|
+
execAsync(`mdfind -onlyin ~ '${query}'`, {
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const paths = stdout
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((item) => item.trim())
|
|
91
|
+
.filter((item) => item.length > 0 && item.endsWith(".zip"));
|
|
92
|
+
|
|
93
|
+
return yield* Effect.forEach(paths, getCandidateEffect, {
|
|
94
|
+
concurrency: "unbounded",
|
|
95
|
+
});
|
|
96
|
+
}).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function findArchivesEffect(): Effect.Effect<
|
|
100
|
+
ArchiveCandidate[],
|
|
101
|
+
unknown
|
|
102
|
+
> {
|
|
82
103
|
if (process.platform !== "darwin") {
|
|
83
|
-
return [];
|
|
104
|
+
return Effect.succeed([]);
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
107
|
+
return Effect.gen(function* () {
|
|
108
|
+
const found = new Map<string, ArchiveCandidate>();
|
|
109
|
+
const downloads = yield* searchDirectoryEffect(
|
|
110
|
+
path.join(homedir(), "Downloads"),
|
|
111
|
+
);
|
|
92
112
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
|
|
97
|
-
];
|
|
113
|
+
for (const candidate of downloads) {
|
|
114
|
+
found.set(candidate.path, candidate);
|
|
115
|
+
}
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
const queries = [
|
|
118
|
+
'kMDItemDisplayName == "twitter-*.zip"',
|
|
119
|
+
'kMDItemDisplayName == "x-*.zip"',
|
|
120
|
+
'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
|
|
121
|
+
];
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
const spotlightCandidates = yield* Effect.forEach(
|
|
124
|
+
queries,
|
|
125
|
+
searchSpotlightEffect,
|
|
126
|
+
{ concurrency: "unbounded" },
|
|
127
|
+
);
|
|
109
128
|
|
|
110
|
-
|
|
111
|
-
paths.map((filePath) => getCandidate(filePath)),
|
|
112
|
-
);
|
|
129
|
+
for (const candidates of spotlightCandidates) {
|
|
113
130
|
for (const candidate of candidates) {
|
|
114
131
|
if (candidate) {
|
|
115
132
|
found.set(candidate.path, candidate);
|
|
116
133
|
}
|
|
117
134
|
}
|
|
118
|
-
} catch {
|
|
119
|
-
// Best-effort only.
|
|
120
135
|
}
|
|
121
|
-
}
|
|
122
136
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
return [...found.values()].sort(
|
|
138
|
+
(left, right) =>
|
|
139
|
+
new Date(right.modifiedTime).getTime() -
|
|
140
|
+
new Date(left.modifiedTime).getTime(),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function findArchives(): Promise<ArchiveCandidate[]> {
|
|
146
|
+
return runEffectPromise(findArchivesEffect());
|
|
128
147
|
}
|