birdclaw 0.4.1 → 0.5.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/CHANGELOG.md +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/scripts/browser-perf.mjs +399 -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 +29 -0
- package/src/cli.ts +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +1582 -67
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +326 -35
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -1,39 +1,136 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
1
3
|
import type { TweetMediaItem } from "#/lib/types";
|
|
2
4
|
import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
3
5
|
|
|
4
6
|
export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
7
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
5
8
|
if (items.length === 0) {
|
|
6
9
|
return null;
|
|
7
10
|
}
|
|
8
11
|
|
|
12
|
+
const visibleItems = items.slice(0, 4);
|
|
13
|
+
const selectedItem =
|
|
14
|
+
selectedIndex === null ? null : (visibleItems[selectedIndex] ?? null);
|
|
15
|
+
const selectedVideoUrl =
|
|
16
|
+
selectedItem?.type === "video" || selectedItem?.type === "gif"
|
|
17
|
+
? (selectedItem.variants?.[0]?.url ?? playableVideoUrl(selectedItem.url))
|
|
18
|
+
: null;
|
|
19
|
+
|
|
9
20
|
return (
|
|
10
|
-
|
|
11
|
-
{items.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
<>
|
|
22
|
+
<div className={tweetMediaGridClass(Math.min(items.length, 4))}>
|
|
23
|
+
{visibleItems.map((item, index) => (
|
|
24
|
+
<button
|
|
25
|
+
key={item.url + String(index)}
|
|
26
|
+
aria-label={`Open tweet media ${String(index + 1)}`}
|
|
27
|
+
className={tweetMediaTileClass(index, Math.min(items.length, 4))}
|
|
28
|
+
onClick={(event) => {
|
|
29
|
+
event.stopPropagation();
|
|
30
|
+
setSelectedIndex(index);
|
|
31
|
+
}}
|
|
32
|
+
style={
|
|
33
|
+
visibleItems.length === 1 && item.width && item.height
|
|
34
|
+
? {
|
|
35
|
+
aspectRatio: `${String(item.width)} / ${String(item.height)}`,
|
|
36
|
+
}
|
|
37
|
+
: undefined
|
|
38
|
+
}
|
|
39
|
+
type="button"
|
|
40
|
+
>
|
|
41
|
+
{item.type === "image" ? (
|
|
42
|
+
<img
|
|
43
|
+
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
|
|
44
|
+
className="tweet-media-image block size-full object-contain"
|
|
45
|
+
loading="lazy"
|
|
46
|
+
src={item.thumbnailUrl ?? item.url}
|
|
47
|
+
/>
|
|
48
|
+
) : (
|
|
49
|
+
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
50
|
+
{item.type === "video"
|
|
51
|
+
? "Video"
|
|
52
|
+
: item.type === "gif"
|
|
53
|
+
? "GIF"
|
|
54
|
+
: "Media"}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</button>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
{selectedItem ? (
|
|
61
|
+
<div
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4"
|
|
64
|
+
onClick={(event) => {
|
|
65
|
+
event.stopPropagation();
|
|
66
|
+
setSelectedIndex(null);
|
|
67
|
+
}}
|
|
68
|
+
role="dialog"
|
|
18
69
|
>
|
|
19
|
-
|
|
70
|
+
<button
|
|
71
|
+
aria-label="Close media viewer"
|
|
72
|
+
className="absolute right-4 top-4 grid size-10 place-items-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
|
|
73
|
+
onClick={(event) => {
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
setSelectedIndex(null);
|
|
76
|
+
}}
|
|
77
|
+
type="button"
|
|
78
|
+
>
|
|
79
|
+
<X className="size-5" strokeWidth={1.8} />
|
|
80
|
+
</button>
|
|
81
|
+
{selectedItem.type === "image" ? (
|
|
20
82
|
<img
|
|
21
|
-
alt={
|
|
22
|
-
className="
|
|
23
|
-
|
|
24
|
-
src={
|
|
83
|
+
alt={selectedItem.altText ?? "Tweet media"}
|
|
84
|
+
className="max-h-[92vh] max-w-[92vw] object-contain"
|
|
85
|
+
onClick={(event) => event.stopPropagation()}
|
|
86
|
+
src={selectedItem.url}
|
|
87
|
+
/>
|
|
88
|
+
) : selectedVideoUrl ? (
|
|
89
|
+
<video
|
|
90
|
+
autoPlay={selectedItem.type === "gif"}
|
|
91
|
+
className="max-h-[92vh] max-w-[92vw]"
|
|
92
|
+
controls
|
|
93
|
+
loop={selectedItem.type === "gif"}
|
|
94
|
+
muted={selectedItem.type === "gif"}
|
|
95
|
+
onClick={(event) => event.stopPropagation()}
|
|
96
|
+
playsInline
|
|
97
|
+
poster={selectedItem.thumbnailUrl}
|
|
98
|
+
src={selectedVideoUrl}
|
|
25
99
|
/>
|
|
26
100
|
) : (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
101
|
+
<div
|
|
102
|
+
className="grid min-h-64 min-w-80 place-items-center gap-3 rounded-2xl border border-white/20 bg-black p-6 text-white"
|
|
103
|
+
onClick={(event) => event.stopPropagation()}
|
|
104
|
+
>
|
|
105
|
+
<span>
|
|
106
|
+
{selectedItem.type === "video"
|
|
107
|
+
? "Video"
|
|
108
|
+
: selectedItem.type === "gif"
|
|
109
|
+
? "GIF"
|
|
110
|
+
: "Media"}
|
|
111
|
+
</span>
|
|
112
|
+
<a
|
|
113
|
+
className="rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/20"
|
|
114
|
+
href={selectedItem.url}
|
|
115
|
+
rel="noreferrer"
|
|
116
|
+
target="_blank"
|
|
117
|
+
>
|
|
118
|
+
Open media
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
34
121
|
)}
|
|
35
|
-
</
|
|
36
|
-
)
|
|
37
|
-
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</>
|
|
38
125
|
);
|
|
39
126
|
}
|
|
127
|
+
|
|
128
|
+
function playableVideoUrl(url: string) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = new URL(url);
|
|
131
|
+
if (parsed.hostname === "video.twimg.com") return url;
|
|
132
|
+
return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(parsed.pathname) ? url : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return /\.(?:mp4|m3u8)(?:$|[?#])/i.test(url) ? url : undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Fragment } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
collectTweetSegments,
|
|
5
|
+
enrichFallbackUrlEntities,
|
|
6
|
+
} from "#/lib/tweet-render";
|
|
3
7
|
import type { TweetEntities } from "#/lib/types";
|
|
4
8
|
import {
|
|
5
9
|
bodyCopyClass,
|
|
@@ -13,12 +17,15 @@ export function TweetRichText({
|
|
|
13
17
|
text,
|
|
14
18
|
entities,
|
|
15
19
|
className = "body-copy",
|
|
20
|
+
hiddenUrlRanges = [],
|
|
16
21
|
}: {
|
|
17
22
|
text: string;
|
|
18
23
|
entities: TweetEntities;
|
|
19
24
|
className?: string;
|
|
25
|
+
hiddenUrlRanges?: Array<{ start: number; end: number }>;
|
|
20
26
|
}) {
|
|
21
|
-
const
|
|
27
|
+
const richEntities = enrichFallbackUrlEntities(text, entities);
|
|
28
|
+
const segments = collectTweetSegments(richEntities);
|
|
22
29
|
let cursor = 0;
|
|
23
30
|
|
|
24
31
|
return (
|
|
@@ -35,12 +42,20 @@ export function TweetRichText({
|
|
|
35
42
|
const prefix = text.slice(cursor, segment.start);
|
|
36
43
|
cursor = segment.end;
|
|
37
44
|
|
|
38
|
-
let node = (
|
|
45
|
+
let node: ReactNode = (
|
|
39
46
|
<Fragment key={`segment-${String(index)}`}>
|
|
40
47
|
{text.slice(segment.start, segment.end)}
|
|
41
48
|
</Fragment>
|
|
42
49
|
);
|
|
43
|
-
if (
|
|
50
|
+
if (
|
|
51
|
+
segment.kind === "url" &&
|
|
52
|
+
hiddenUrlRanges.some(
|
|
53
|
+
(range) =>
|
|
54
|
+
range.start === segment.start && range.end === segment.end,
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
node = null;
|
|
58
|
+
} else if (segment.kind === "mention" && segment.profile) {
|
|
44
59
|
node = (
|
|
45
60
|
<ProfilePreview
|
|
46
61
|
key={`segment-${String(index)}`}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
QueryEnvelope,
|
|
4
|
+
ReplyFilter,
|
|
5
|
+
ResourceKind,
|
|
6
|
+
TimelineItem,
|
|
7
|
+
} from "#/lib/types";
|
|
8
|
+
import {
|
|
9
|
+
fetchQueryEnvelope,
|
|
10
|
+
fetchQueryResponse,
|
|
11
|
+
postAction,
|
|
12
|
+
} from "#/lib/api-client";
|
|
13
|
+
|
|
14
|
+
interface UseTimelineRouteDataOptions {
|
|
15
|
+
resource: Exclude<ResourceKind, "dms">;
|
|
16
|
+
search: string;
|
|
17
|
+
errorFallback: string;
|
|
18
|
+
replyFilter?: ReplyFilter;
|
|
19
|
+
likedOnly?: boolean;
|
|
20
|
+
bookmarkedOnly?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTimelineRouteData({
|
|
24
|
+
resource,
|
|
25
|
+
search,
|
|
26
|
+
errorFallback,
|
|
27
|
+
replyFilter,
|
|
28
|
+
likedOnly = false,
|
|
29
|
+
bookmarkedOnly = false,
|
|
30
|
+
}: UseTimelineRouteDataOptions) {
|
|
31
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
32
|
+
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
33
|
+
const [loading, setLoading] = useState(true);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
36
|
+
|
|
37
|
+
async function loadStatus() {
|
|
38
|
+
setMeta(await fetchQueryEnvelope());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
void loadStatus();
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const url = new URL("/api/query", window.location.origin);
|
|
47
|
+
url.searchParams.set("resource", resource);
|
|
48
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
49
|
+
if (replyFilter) {
|
|
50
|
+
url.searchParams.set("replyFilter", replyFilter);
|
|
51
|
+
}
|
|
52
|
+
if (likedOnly) {
|
|
53
|
+
url.searchParams.set("liked", "true");
|
|
54
|
+
}
|
|
55
|
+
if (bookmarkedOnly) {
|
|
56
|
+
url.searchParams.set("bookmarked", "true");
|
|
57
|
+
}
|
|
58
|
+
if (search.trim()) {
|
|
59
|
+
url.searchParams.set("search", search.trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
let active = true;
|
|
64
|
+
setError(null);
|
|
65
|
+
setLoading(true);
|
|
66
|
+
fetchQueryResponse(url, { signal: controller.signal })
|
|
67
|
+
.then((data) => {
|
|
68
|
+
if (active) {
|
|
69
|
+
setItems(data.items as TimelineItem[]);
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch((fetchError: unknown) => {
|
|
73
|
+
if (
|
|
74
|
+
fetchError instanceof DOMException &&
|
|
75
|
+
fetchError.name === "AbortError"
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!active) return;
|
|
80
|
+
setError(
|
|
81
|
+
fetchError instanceof Error ? fetchError.message : errorFallback,
|
|
82
|
+
);
|
|
83
|
+
setItems([]);
|
|
84
|
+
})
|
|
85
|
+
.finally(() => {
|
|
86
|
+
if (active) {
|
|
87
|
+
setLoading(false);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
active = false;
|
|
93
|
+
controller.abort();
|
|
94
|
+
};
|
|
95
|
+
}, [
|
|
96
|
+
bookmarkedOnly,
|
|
97
|
+
errorFallback,
|
|
98
|
+
likedOnly,
|
|
99
|
+
refreshTick,
|
|
100
|
+
replyFilter,
|
|
101
|
+
resource,
|
|
102
|
+
search,
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function retry() {
|
|
106
|
+
setRefreshTick((value) => value + 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function refreshLocalView() {
|
|
110
|
+
setRefreshTick((value) => value + 1);
|
|
111
|
+
void loadStatus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function replyToTweet(tweetId: string) {
|
|
115
|
+
const text = window.prompt("Reply text");
|
|
116
|
+
if (!text?.trim()) return;
|
|
117
|
+
|
|
118
|
+
await postAction({
|
|
119
|
+
kind: "replyTweet",
|
|
120
|
+
accountId: "acct_primary",
|
|
121
|
+
tweetId,
|
|
122
|
+
text,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
retry();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
meta,
|
|
130
|
+
items,
|
|
131
|
+
loading,
|
|
132
|
+
error,
|
|
133
|
+
retry,
|
|
134
|
+
refreshLocalView,
|
|
135
|
+
replyToTweet,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
DmConversationItem,
|
|
4
|
+
DmMessageItem,
|
|
5
|
+
QueryEnvelope,
|
|
6
|
+
QueryResponse,
|
|
7
|
+
TimelineItem,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import type {
|
|
10
|
+
WebSyncJobSnapshot,
|
|
11
|
+
WebSyncKind,
|
|
12
|
+
WebSyncResponse,
|
|
13
|
+
} from "./web-sync";
|
|
14
|
+
|
|
15
|
+
const jsonRecordSchema = z.object({}).passthrough();
|
|
16
|
+
const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
|
|
17
|
+
const webSyncKindSchema = z.enum([
|
|
18
|
+
"timeline",
|
|
19
|
+
"mentions",
|
|
20
|
+
"likes",
|
|
21
|
+
"bookmarks",
|
|
22
|
+
"dms",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const queryEnvelopeSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
accounts: z.array(jsonRecordSchema),
|
|
28
|
+
archives: z.array(jsonRecordSchema),
|
|
29
|
+
transport: z
|
|
30
|
+
.object({
|
|
31
|
+
statusText: z.string(),
|
|
32
|
+
})
|
|
33
|
+
.passthrough(),
|
|
34
|
+
stats: z.object({
|
|
35
|
+
home: z.number(),
|
|
36
|
+
mentions: z.number(),
|
|
37
|
+
dms: z.number(),
|
|
38
|
+
needsReply: z.number(),
|
|
39
|
+
inbox: z.number(),
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
.transform((value) => value as unknown as QueryEnvelope);
|
|
43
|
+
|
|
44
|
+
const queryResponseSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
resource: resourceKindSchema,
|
|
47
|
+
items: z.array(jsonRecordSchema),
|
|
48
|
+
selectedConversation: z
|
|
49
|
+
.object({
|
|
50
|
+
conversation: jsonRecordSchema,
|
|
51
|
+
messages: z.array(jsonRecordSchema),
|
|
52
|
+
})
|
|
53
|
+
.nullish(),
|
|
54
|
+
})
|
|
55
|
+
.transform(
|
|
56
|
+
(value) =>
|
|
57
|
+
({
|
|
58
|
+
...value,
|
|
59
|
+
items: value.items as unknown as Array<
|
|
60
|
+
TimelineItem | DmConversationItem
|
|
61
|
+
>,
|
|
62
|
+
selectedConversation: value.selectedConversation
|
|
63
|
+
? {
|
|
64
|
+
conversation: value.selectedConversation
|
|
65
|
+
.conversation as unknown as DmConversationItem,
|
|
66
|
+
messages: value.selectedConversation
|
|
67
|
+
.messages as unknown as DmMessageItem[],
|
|
68
|
+
}
|
|
69
|
+
: value.selectedConversation,
|
|
70
|
+
}) as QueryResponse,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const webSyncResponseSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
ok: z.boolean(),
|
|
76
|
+
kind: webSyncKindSchema,
|
|
77
|
+
accountId: z.string().optional(),
|
|
78
|
+
summary: z.string(),
|
|
79
|
+
steps: z.array(jsonRecordSchema),
|
|
80
|
+
startedAt: z.string().optional(),
|
|
81
|
+
finishedAt: z.string().optional(),
|
|
82
|
+
inProgress: z.boolean().optional(),
|
|
83
|
+
backup: z.unknown().optional(),
|
|
84
|
+
error: z.string().optional(),
|
|
85
|
+
})
|
|
86
|
+
.transform((value) => value as unknown as WebSyncResponse);
|
|
87
|
+
|
|
88
|
+
const webSyncJobSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
id: z.string(),
|
|
91
|
+
kind: webSyncKindSchema,
|
|
92
|
+
accountId: z.string().optional(),
|
|
93
|
+
status: z.enum(["running", "succeeded", "failed"]),
|
|
94
|
+
startedAt: z.string(),
|
|
95
|
+
finishedAt: z.string().optional(),
|
|
96
|
+
summary: z.string(),
|
|
97
|
+
inProgress: z.boolean(),
|
|
98
|
+
result: webSyncResponseSchema.optional(),
|
|
99
|
+
error: z.string().optional(),
|
|
100
|
+
})
|
|
101
|
+
.transform((value) => value as unknown as WebSyncJobSnapshot);
|
|
102
|
+
|
|
103
|
+
const actionResponseSchema = jsonRecordSchema;
|
|
104
|
+
const SYNC_POLL_INTERVAL_MS = 500;
|
|
105
|
+
|
|
106
|
+
export class ApiFetchError extends Error {
|
|
107
|
+
constructor(
|
|
108
|
+
message: string,
|
|
109
|
+
readonly status?: number,
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.name = "ApiFetchError";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function responseMessage(data: unknown, fallback: string) {
|
|
117
|
+
if (data && typeof data === "object") {
|
|
118
|
+
const record = data as {
|
|
119
|
+
message?: unknown;
|
|
120
|
+
error?: unknown;
|
|
121
|
+
summary?: unknown;
|
|
122
|
+
};
|
|
123
|
+
if (typeof record.message === "string") return record.message;
|
|
124
|
+
if (typeof record.error === "string") return record.error;
|
|
125
|
+
if (typeof record.summary === "string") return record.summary;
|
|
126
|
+
}
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readJson(response: Response) {
|
|
131
|
+
try {
|
|
132
|
+
return await response.json();
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function fetchJson<T>(
|
|
139
|
+
input: RequestInfo | URL,
|
|
140
|
+
init: RequestInit | undefined,
|
|
141
|
+
schema: z.ZodType<T>,
|
|
142
|
+
fallbackMessage: string,
|
|
143
|
+
): Promise<T> {
|
|
144
|
+
const response = await fetch(input, init);
|
|
145
|
+
const data = await readJson(response);
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new ApiFetchError(
|
|
148
|
+
responseMessage(data, fallbackMessage),
|
|
149
|
+
response.status,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = schema.safeParse(data);
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
throw new ApiFetchError(fallbackMessage);
|
|
156
|
+
}
|
|
157
|
+
return parsed.data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function fetchQueryEnvelope(init?: RequestInit) {
|
|
161
|
+
return fetchJson(
|
|
162
|
+
"/api/status",
|
|
163
|
+
init,
|
|
164
|
+
queryEnvelopeSchema,
|
|
165
|
+
"Status unavailable",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function fetchQueryResponse(
|
|
170
|
+
input: RequestInfo | URL,
|
|
171
|
+
init?: RequestInit,
|
|
172
|
+
) {
|
|
173
|
+
return fetchJson(input, init, queryResponseSchema, "Query unavailable");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function postAction(body: Record<string, unknown>) {
|
|
177
|
+
return fetchJson(
|
|
178
|
+
"/api/action",
|
|
179
|
+
{
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "content-type": "application/json" },
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
},
|
|
184
|
+
actionResponseSchema,
|
|
185
|
+
"Action failed",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function postSync(kind: WebSyncKind, accountId?: string) {
|
|
190
|
+
return fetchJson(
|
|
191
|
+
"/api/sync",
|
|
192
|
+
{
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "content-type": "application/json" },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
kind,
|
|
197
|
+
...(accountId ? { accountId } : {}),
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
webSyncJobSchema,
|
|
201
|
+
"Sync failed",
|
|
202
|
+
).then(waitForWebSyncJob);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function fetchSyncJob(id: string) {
|
|
206
|
+
const url = new URL("/api/sync", window.location.origin);
|
|
207
|
+
url.searchParams.set("id", id);
|
|
208
|
+
return fetchJson(url, undefined, webSyncJobSchema, "Sync status unavailable");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function delay(ms: number) {
|
|
212
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function waitForWebSyncJob(job: WebSyncJobSnapshot) {
|
|
216
|
+
let current = job;
|
|
217
|
+
while (current.inProgress) {
|
|
218
|
+
await delay(SYNC_POLL_INTERVAL_MS);
|
|
219
|
+
current = await fetchSyncJob(current.id);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!current.result) {
|
|
223
|
+
throw new ApiFetchError(current.error ?? current.summary);
|
|
224
|
+
}
|
|
225
|
+
if (!current.result.ok) {
|
|
226
|
+
throw new ApiFetchError(current.result.error ?? current.result.summary);
|
|
227
|
+
}
|
|
228
|
+
return current.result;
|
|
229
|
+
}
|
|
@@ -96,27 +96,31 @@ export async function findArchives(): Promise<ArchiveCandidate[]> {
|
|
|
96
96
|
'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
|
|
97
97
|
];
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
paths.map((filePath) => getCandidate(filePath))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
99
|
+
const spotlightCandidates = await Promise.all(
|
|
100
|
+
queries.map(async (query) => {
|
|
101
|
+
try {
|
|
102
|
+
const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const paths = stdout
|
|
107
|
+
.split("\n")
|
|
108
|
+
.map((item) => item.trim())
|
|
109
|
+
.filter((item) => item.length > 0 && item.endsWith(".zip"));
|
|
110
|
+
|
|
111
|
+
return Promise.all(paths.map((filePath) => getCandidate(filePath)));
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort only.
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
for (const candidates of spotlightCandidates) {
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (candidate) {
|
|
122
|
+
found.set(candidate.path, candidate);
|
|
117
123
|
}
|
|
118
|
-
} catch {
|
|
119
|
-
// Best-effort only.
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
|