birdclaw 0.1.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
2
|
+
import type { TimelineItem } from "#/lib/types";
|
|
3
|
+
import {
|
|
4
|
+
actionButtonClass,
|
|
5
|
+
cardFooterClass,
|
|
6
|
+
cardHeaderClass,
|
|
7
|
+
contentCardClass,
|
|
8
|
+
cx,
|
|
9
|
+
identityBlockClass,
|
|
10
|
+
identityRowClass,
|
|
11
|
+
linkPreviewCardClass,
|
|
12
|
+
metaStackClass,
|
|
13
|
+
metricRowClass,
|
|
14
|
+
mutedDotClass,
|
|
15
|
+
pillAlertClass,
|
|
16
|
+
pillClass,
|
|
17
|
+
pillSoftClass,
|
|
18
|
+
timestampClass,
|
|
19
|
+
} from "#/lib/ui";
|
|
20
|
+
import { AvatarChip } from "./AvatarChip";
|
|
21
|
+
import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
|
|
22
|
+
import { ProfilePreview } from "./ProfilePreview";
|
|
23
|
+
import { TweetMediaGrid } from "./TweetMediaGrid";
|
|
24
|
+
import { TweetRichText } from "./TweetRichText";
|
|
25
|
+
|
|
26
|
+
function getVisibleUrlCards(item: TimelineItem) {
|
|
27
|
+
const quotedUrl = item.quotedTweet ? item.quotedTweet.id : null;
|
|
28
|
+
return (item.entities.urls ?? []).filter((entry) => {
|
|
29
|
+
if (!item.quotedTweet) return true;
|
|
30
|
+
return !entry.expandedUrl.includes(quotedUrl ?? "");
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function TimelineCard({
|
|
35
|
+
item,
|
|
36
|
+
onReply,
|
|
37
|
+
}: {
|
|
38
|
+
item: TimelineItem;
|
|
39
|
+
onReply: (tweetId: string) => void;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<article className={contentCardClass}>
|
|
43
|
+
<header className={cardHeaderClass}>
|
|
44
|
+
<div className={identityBlockClass}>
|
|
45
|
+
<AvatarChip
|
|
46
|
+
avatarUrl={item.author.avatarUrl}
|
|
47
|
+
hue={item.author.avatarHue}
|
|
48
|
+
name={item.author.displayName}
|
|
49
|
+
profileId={item.author.id}
|
|
50
|
+
/>
|
|
51
|
+
<div>
|
|
52
|
+
<ProfilePreview profile={item.author}>
|
|
53
|
+
<div className={identityRowClass}>
|
|
54
|
+
<strong>{item.author.displayName}</strong>
|
|
55
|
+
<span>@{item.author.handle}</span>
|
|
56
|
+
<span className={mutedDotClass} />
|
|
57
|
+
<span>
|
|
58
|
+
{formatCompactNumber(item.author.followersCount)} followers
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
</ProfilePreview>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div className={metaStackClass}>
|
|
65
|
+
<span
|
|
66
|
+
className={cx(
|
|
67
|
+
pillClass,
|
|
68
|
+
item.isReplied ? pillSoftClass : pillAlertClass,
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{item.isReplied ? "replied" : "needs reply"}
|
|
72
|
+
</span>
|
|
73
|
+
<span className={timestampClass}>
|
|
74
|
+
{formatShortTimestamp(item.createdAt)}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
</header>
|
|
78
|
+
<TweetRichText entities={item.entities} text={item.text} />
|
|
79
|
+
<TweetMediaGrid items={item.media} />
|
|
80
|
+
{item.replyToTweet ? (
|
|
81
|
+
<EmbeddedTweetCard item={item.replyToTweet} label="In reply to" />
|
|
82
|
+
) : null}
|
|
83
|
+
{item.quotedTweet ? (
|
|
84
|
+
<EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
|
|
85
|
+
) : null}
|
|
86
|
+
{getVisibleUrlCards(item).map((entry) => (
|
|
87
|
+
<a
|
|
88
|
+
key={entry.expandedUrl}
|
|
89
|
+
className={linkPreviewCardClass}
|
|
90
|
+
href={entry.expandedUrl}
|
|
91
|
+
rel="noreferrer"
|
|
92
|
+
target="_blank"
|
|
93
|
+
>
|
|
94
|
+
<strong>{entry.title ?? entry.displayUrl}</strong>
|
|
95
|
+
<span className="text-[var(--ink-soft)]">
|
|
96
|
+
{entry.description ?? entry.displayUrl}
|
|
97
|
+
</span>
|
|
98
|
+
<span className={timestampClass}>{entry.displayUrl}</span>
|
|
99
|
+
</a>
|
|
100
|
+
))}
|
|
101
|
+
<footer className={cardFooterClass}>
|
|
102
|
+
<div className={metricRowClass}>
|
|
103
|
+
<span>{formatCompactNumber(item.likeCount)} likes</span>
|
|
104
|
+
<span>{item.mediaCount} media</span>
|
|
105
|
+
<span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
|
|
106
|
+
<span>{item.accountHandle}</span>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
className={actionButtonClass}
|
|
110
|
+
onClick={() => onReply(item.id)}
|
|
111
|
+
type="button"
|
|
112
|
+
>
|
|
113
|
+
Reply
|
|
114
|
+
</button>
|
|
115
|
+
</footer>
|
|
116
|
+
</article>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TweetMediaItem } from "#/lib/types";
|
|
2
|
+
import { tweetMediaGridClass, tweetMediaTileClass } from "#/lib/ui";
|
|
3
|
+
|
|
4
|
+
export function TweetMediaGrid({ items }: { items: TweetMediaItem[] }) {
|
|
5
|
+
if (items.length === 0) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className={tweetMediaGridClass(Math.min(items.length, 4))}>
|
|
11
|
+
{items.slice(0, 4).map((item, index) => (
|
|
12
|
+
<a
|
|
13
|
+
key={item.url + String(index)}
|
|
14
|
+
className={tweetMediaTileClass(index, Math.min(items.length, 4))}
|
|
15
|
+
href={item.url}
|
|
16
|
+
rel="noreferrer"
|
|
17
|
+
target="_blank"
|
|
18
|
+
>
|
|
19
|
+
{item.type === "image" ? (
|
|
20
|
+
<img
|
|
21
|
+
alt={item.altText ?? `Tweet media ${String(index + 1)}`}
|
|
22
|
+
className="tweet-media-image block size-full object-cover"
|
|
23
|
+
loading="lazy"
|
|
24
|
+
src={item.thumbnailUrl ?? item.url}
|
|
25
|
+
/>
|
|
26
|
+
) : (
|
|
27
|
+
<span className="tweet-media-fallback grid min-h-40 place-items-center font-semibold text-[var(--ink-soft)]">
|
|
28
|
+
{item.type === "video"
|
|
29
|
+
? "Video"
|
|
30
|
+
: item.type === "gif"
|
|
31
|
+
? "GIF"
|
|
32
|
+
: "Media"}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
</a>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
import { collectTweetSegments } from "#/lib/tweet-render";
|
|
3
|
+
import type { TweetEntities } from "#/lib/types";
|
|
4
|
+
import {
|
|
5
|
+
bodyCopyClass,
|
|
6
|
+
tweetHashtagClass,
|
|
7
|
+
tweetLinkClass,
|
|
8
|
+
tweetMentionClass,
|
|
9
|
+
} from "#/lib/ui";
|
|
10
|
+
import { ProfilePreview } from "./ProfilePreview";
|
|
11
|
+
|
|
12
|
+
export function TweetRichText({
|
|
13
|
+
text,
|
|
14
|
+
entities,
|
|
15
|
+
className = "body-copy",
|
|
16
|
+
}: {
|
|
17
|
+
text: string;
|
|
18
|
+
entities: TweetEntities;
|
|
19
|
+
className?: string;
|
|
20
|
+
}) {
|
|
21
|
+
const segments = collectTweetSegments(entities);
|
|
22
|
+
let cursor = 0;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<p className={className === "body-copy" ? bodyCopyClass : className}>
|
|
26
|
+
{segments.map((segment, index) => {
|
|
27
|
+
if (
|
|
28
|
+
segment.start < cursor ||
|
|
29
|
+
segment.end <= segment.start ||
|
|
30
|
+
segment.end > text.length
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const prefix = text.slice(cursor, segment.start);
|
|
36
|
+
cursor = segment.end;
|
|
37
|
+
|
|
38
|
+
let node = (
|
|
39
|
+
<Fragment key={`segment-${String(index)}`}>
|
|
40
|
+
{text.slice(segment.start, segment.end)}
|
|
41
|
+
</Fragment>
|
|
42
|
+
);
|
|
43
|
+
if (segment.kind === "mention" && segment.profile) {
|
|
44
|
+
node = (
|
|
45
|
+
<ProfilePreview
|
|
46
|
+
key={`segment-${String(index)}`}
|
|
47
|
+
profile={segment.profile}
|
|
48
|
+
>
|
|
49
|
+
<span className={tweetMentionClass}>@{segment.username}</span>
|
|
50
|
+
</ProfilePreview>
|
|
51
|
+
);
|
|
52
|
+
} else if (segment.kind === "url") {
|
|
53
|
+
node = (
|
|
54
|
+
<a
|
|
55
|
+
key={`segment-${String(index)}`}
|
|
56
|
+
className={tweetLinkClass}
|
|
57
|
+
href={segment.expandedUrl}
|
|
58
|
+
rel="noreferrer"
|
|
59
|
+
target="_blank"
|
|
60
|
+
>
|
|
61
|
+
{segment.displayUrl}
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
} else if (segment.kind === "hashtag") {
|
|
65
|
+
node = (
|
|
66
|
+
<span
|
|
67
|
+
className={tweetHashtagClass}
|
|
68
|
+
key={`segment-${String(index)}`}
|
|
69
|
+
>
|
|
70
|
+
#{segment.tag}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Fragment key={`piece-${String(index)}`}>
|
|
77
|
+
{prefix}
|
|
78
|
+
{node}
|
|
79
|
+
</Fragment>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
{text.slice(cursor)}
|
|
83
|
+
</p>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
blockUserViaBird,
|
|
3
|
+
muteUserViaBird,
|
|
4
|
+
readBirdStatusViaBird,
|
|
5
|
+
unblockUserViaBird,
|
|
6
|
+
unmuteUserViaBird,
|
|
7
|
+
} from "./bird-actions";
|
|
8
|
+
import { type ActionsTransport, resolveActionsTransport } from "./config";
|
|
9
|
+
import type {
|
|
10
|
+
ModerationAction,
|
|
11
|
+
ModerationActionTransportResult,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import {
|
|
14
|
+
blockUserViaXurl,
|
|
15
|
+
lookupAuthenticatedUser,
|
|
16
|
+
muteUserViaXurl,
|
|
17
|
+
unblockUserViaXurl,
|
|
18
|
+
unmuteUserViaXurl,
|
|
19
|
+
} from "./xurl";
|
|
20
|
+
|
|
21
|
+
export type ActionTransportResult = ModerationActionTransportResult;
|
|
22
|
+
|
|
23
|
+
interface RunActionParams {
|
|
24
|
+
action: ModerationAction;
|
|
25
|
+
query: string;
|
|
26
|
+
targetUserId?: string;
|
|
27
|
+
transport?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeFailure(transport: "bird" | "xurl", output: string) {
|
|
31
|
+
return `${transport}: ${output}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runBirdAction(
|
|
35
|
+
action: ModerationAction,
|
|
36
|
+
query: string,
|
|
37
|
+
): Promise<ActionTransportResult> {
|
|
38
|
+
const result =
|
|
39
|
+
action === "block"
|
|
40
|
+
? await blockUserViaBird(query)
|
|
41
|
+
: action === "unblock"
|
|
42
|
+
? await unblockUserViaBird(query)
|
|
43
|
+
: action === "mute"
|
|
44
|
+
? await muteUserViaBird(query)
|
|
45
|
+
: await unmuteUserViaBird(query);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
transport: "bird",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getVerifyExpectation(action: ModerationAction) {
|
|
54
|
+
return action === "block" || action === "unblock"
|
|
55
|
+
? {
|
|
56
|
+
field: "blocking" as const,
|
|
57
|
+
expected: action === "block",
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
field: "muting" as const,
|
|
61
|
+
expected: action === "mute",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runXurlAction(
|
|
66
|
+
action: ModerationAction,
|
|
67
|
+
query: string,
|
|
68
|
+
targetUserId?: string,
|
|
69
|
+
): Promise<ActionTransportResult> {
|
|
70
|
+
if (!targetUserId) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
output: "missing target user id for xurl transport",
|
|
74
|
+
transport: "xurl",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sourceUser = await lookupAuthenticatedUser();
|
|
79
|
+
const sourceUserId =
|
|
80
|
+
sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
|
|
81
|
+
if (!sourceUserId) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
output: "xurl authenticated user unavailable",
|
|
85
|
+
transport: "xurl",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result =
|
|
90
|
+
action === "block"
|
|
91
|
+
? await blockUserViaXurl(sourceUserId, targetUserId)
|
|
92
|
+
: action === "unblock"
|
|
93
|
+
? await unblockUserViaXurl(sourceUserId, targetUserId)
|
|
94
|
+
: action === "mute"
|
|
95
|
+
? await muteUserViaXurl(sourceUserId, targetUserId)
|
|
96
|
+
: await unmuteUserViaXurl(sourceUserId, targetUserId);
|
|
97
|
+
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
return {
|
|
100
|
+
...result,
|
|
101
|
+
transport: "xurl",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const status = await readBirdStatusViaBird(query);
|
|
106
|
+
const { field: verifyField, expected: expectedValue } =
|
|
107
|
+
getVerifyExpectation(action);
|
|
108
|
+
const actualValue =
|
|
109
|
+
status && typeof status[verifyField] === "boolean"
|
|
110
|
+
? Boolean(status[verifyField])
|
|
111
|
+
: null;
|
|
112
|
+
|
|
113
|
+
if (actualValue === null) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
output: `${result.output}\nxurl verify unavailable from bird status`,
|
|
117
|
+
transport: "xurl",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (actualValue !== expectedValue) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
|
|
125
|
+
transport: "xurl",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
|
|
132
|
+
transport: "xurl",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runModerationAction({
|
|
137
|
+
action,
|
|
138
|
+
query,
|
|
139
|
+
targetUserId,
|
|
140
|
+
transport,
|
|
141
|
+
}: RunActionParams): Promise<ActionTransportResult> {
|
|
142
|
+
const requestedTransport = resolveActionsTransport(transport);
|
|
143
|
+
if (requestedTransport === "bird") {
|
|
144
|
+
return runBirdAction(action, query);
|
|
145
|
+
}
|
|
146
|
+
if (requestedTransport === "xurl") {
|
|
147
|
+
return runXurlAction(action, query, targetUserId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const birdResult = await runBirdAction(action, query);
|
|
151
|
+
if (birdResult.ok) {
|
|
152
|
+
return birdResult;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const xurlResult = await runXurlAction(action, query, targetUserId);
|
|
156
|
+
if (xurlResult.ok) {
|
|
157
|
+
return {
|
|
158
|
+
...xurlResult,
|
|
159
|
+
output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
output: [
|
|
166
|
+
normalizeFailure("bird", birdResult.output),
|
|
167
|
+
normalizeFailure("xurl", xurlResult.output),
|
|
168
|
+
].join("\n"),
|
|
169
|
+
transport: xurlResult.transport,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type { ActionsTransport };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import type { ArchiveCandidate } from "./types";
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const ARCHIVE_NAME_PATTERNS = [
|
|
10
|
+
/^twitter-.*\.zip$/i,
|
|
11
|
+
/^x-.*\.zip$/i,
|
|
12
|
+
/archive.*\.zip$/i,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function formatFileSize(bytes: number): string {
|
|
16
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
17
|
+
let value = bytes;
|
|
18
|
+
let index = 0;
|
|
19
|
+
|
|
20
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
21
|
+
value /= 1024;
|
|
22
|
+
index += 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatRelativeDate(date: Date): string {
|
|
29
|
+
const days = Math.floor(
|
|
30
|
+
(Date.now() - date.getTime()) / (1000 * 60 * 60 * 24),
|
|
31
|
+
);
|
|
32
|
+
if (days <= 0) return "Today";
|
|
33
|
+
if (days === 1) return "Yesterday";
|
|
34
|
+
if (days < 7) return `${days} days ago`;
|
|
35
|
+
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
|
36
|
+
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
|
37
|
+
return `${Math.floor(days / 365)} years ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function getCandidate(
|
|
41
|
+
filePath: string,
|
|
42
|
+
): Promise<ArchiveCandidate | null> {
|
|
43
|
+
try {
|
|
44
|
+
const stats = await fs.stat(filePath);
|
|
45
|
+
if (!stats.isFile() || stats.size < 1024 * 1024) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
path: filePath,
|
|
51
|
+
name: path.basename(filePath),
|
|
52
|
+
size: stats.size,
|
|
53
|
+
sizeFormatted: formatFileSize(stats.size),
|
|
54
|
+
modifiedTime: stats.mtime.toISOString(),
|
|
55
|
+
dateFormatted: formatRelativeDate(stats.mtime),
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function searchDirectory(
|
|
63
|
+
directoryPath: string,
|
|
64
|
+
): Promise<ArchiveCandidate[]> {
|
|
65
|
+
if (!existsSync(directoryPath)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entries = await fs.readdir(directoryPath);
|
|
70
|
+
const matches = entries.filter((entry) =>
|
|
71
|
+
ARCHIVE_NAME_PATTERNS.some((pattern) => pattern.test(entry)),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const candidates = await Promise.all(
|
|
75
|
+
matches.map((entry) => getCandidate(path.join(directoryPath, entry))),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return candidates.filter((item) => item !== null);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function findArchives(): Promise<ArchiveCandidate[]> {
|
|
82
|
+
if (process.platform !== "darwin") {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const found = new Map<string, ArchiveCandidate>();
|
|
87
|
+
const downloads = await searchDirectory(path.join(homedir(), "Downloads"));
|
|
88
|
+
|
|
89
|
+
for (const candidate of downloads) {
|
|
90
|
+
found.set(candidate.path, candidate);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const queries = [
|
|
94
|
+
'kMDItemDisplayName == "twitter-*.zip"',
|
|
95
|
+
'kMDItemDisplayName == "x-*.zip"',
|
|
96
|
+
'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const query of queries) {
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
|
|
102
|
+
timeout: 5000,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const paths = stdout
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((item) => item.trim())
|
|
108
|
+
.filter((item) => item.length > 0 && item.endsWith(".zip"));
|
|
109
|
+
|
|
110
|
+
const candidates = await Promise.all(
|
|
111
|
+
paths.map((filePath) => getCandidate(filePath)),
|
|
112
|
+
);
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (candidate) {
|
|
115
|
+
found.set(candidate.path, candidate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Best-effort only.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [...found.values()].sort(
|
|
124
|
+
(left, right) =>
|
|
125
|
+
new Date(right.modifiedTime).getTime() -
|
|
126
|
+
new Date(left.modifiedTime).getTime(),
|
|
127
|
+
);
|
|
128
|
+
}
|