@kyoji2/raindrop-cli 0.1.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.
@@ -0,0 +1,74 @@
1
+ import type { z } from "zod";
2
+ import type {
3
+ CollectionSchema,
4
+ HighlightSchema,
5
+ MediaSchema,
6
+ RaindropSchema,
7
+ RaindropUserSchema,
8
+ TagSchema,
9
+ UserStatsSchema,
10
+ } from "./schemas";
11
+
12
+ export type RaindropUser = z.infer<typeof RaindropUserSchema>;
13
+ export type Collection = z.infer<typeof CollectionSchema>;
14
+ export type Highlight = z.infer<typeof HighlightSchema>;
15
+ export type Media = z.infer<typeof MediaSchema>;
16
+ export type Raindrop = z.infer<typeof RaindropSchema>;
17
+ export type Tag = z.infer<typeof TagSchema>;
18
+ export type UserStats = z.infer<typeof UserStatsSchema>;
19
+
20
+ export interface CollectionCreate {
21
+ title: string;
22
+ parent?: { $id: number };
23
+ public?: boolean;
24
+ view?: "list" | "simple" | "grid" | "masonry";
25
+ sort?: number;
26
+ cover?: string[];
27
+ color?: string;
28
+ }
29
+
30
+ export interface CollectionUpdate {
31
+ title?: string;
32
+ parent?: { $id: number };
33
+ public?: boolean;
34
+ view?: "list" | "simple" | "grid" | "masonry";
35
+ sort?: number;
36
+ cover?: string[];
37
+ color?: string;
38
+ expanded?: boolean;
39
+ }
40
+
41
+ export interface RaindropCreate {
42
+ link: string;
43
+ title?: string;
44
+ excerpt?: string;
45
+ tags?: string[];
46
+ collection?: { $id: number };
47
+ type?: string;
48
+ important?: boolean;
49
+ cover?: string;
50
+ note?: string;
51
+ highlights?: Highlight[];
52
+ }
53
+
54
+ export interface RaindropUpdate {
55
+ title?: string;
56
+ excerpt?: string;
57
+ tags?: string[];
58
+ collection?: { $id: number };
59
+ cover?: string;
60
+ important?: boolean;
61
+ note?: string;
62
+ link?: string;
63
+ order?: number;
64
+ pleaseParse?: boolean;
65
+ }
66
+
67
+ export interface ApiResponse<T> {
68
+ result: boolean;
69
+ item?: T;
70
+ items?: T[];
71
+ count?: number;
72
+ user?: RaindropUser;
73
+ errorMessage?: string;
74
+ }
@@ -0,0 +1,58 @@
1
+ import { RaindropAPI } from "../api";
2
+ import { deleteConfig, getToken, saveConfig } from "../utils/config";
3
+ import { type GlobalOptions, output, outputError } from "../utils/output";
4
+ import { withSpinner } from "../utils/spinner";
5
+
6
+ export interface LoginOptions extends GlobalOptions {
7
+ token?: string;
8
+ }
9
+
10
+ export async function cmdLogin(options: LoginOptions): Promise<void> {
11
+ let token = options.token ?? "";
12
+
13
+ if (!token) {
14
+ process.stdout.write("Enter your Raindrop.io API Token: ");
15
+ const prompt = await import("node:readline");
16
+ const rl = prompt.createInterface({
17
+ input: process.stdin,
18
+ output: process.stdout,
19
+ });
20
+ token = await new Promise((resolve) => {
21
+ rl.question("", (answer) => {
22
+ rl.close();
23
+ resolve(answer);
24
+ });
25
+ });
26
+ }
27
+
28
+ if (!token) {
29
+ outputError("Token is required", 400);
30
+ }
31
+
32
+ const api = new RaindropAPI(token);
33
+ const user = await withSpinner("Verifying token...", () => api.getUser(), `Logged in as ${token.slice(0, 8)}...`);
34
+
35
+ await saveConfig({ token });
36
+ console.log(`Welcome, ${user.fullName}!`);
37
+ }
38
+
39
+ export async function cmdLogout(_options: GlobalOptions): Promise<void> {
40
+ await deleteConfig();
41
+ console.log("Logged out. Credentials removed.");
42
+ }
43
+
44
+ export async function cmdWhoami(options: GlobalOptions): Promise<void> {
45
+ const api = getAuthenticatedAPI(options);
46
+ const user = await withSpinner("Fetching user info...", () => api.getUser());
47
+ output(user, options.format);
48
+ }
49
+
50
+ function getAuthenticatedAPI(options: GlobalOptions): RaindropAPI {
51
+ const token = getToken();
52
+ if (!token) {
53
+ outputError("Not logged in. Run `raindrop login` first.", 401);
54
+ }
55
+ return new RaindropAPI(token, options.dryRun);
56
+ }
57
+
58
+ export { getAuthenticatedAPI };
@@ -0,0 +1,50 @@
1
+ import type { RaindropUpdate } from "../api";
2
+ import { type GlobalOptions, output, outputError } from "../utils/output";
3
+ import { withSpinner } from "../utils/spinner";
4
+ import { getAuthenticatedAPI } from "./auth";
5
+
6
+ export interface BatchUpdateOptions extends GlobalOptions {
7
+ ids: string;
8
+ json: string;
9
+ collection?: string;
10
+ }
11
+
12
+ export async function cmdBatchUpdate(options: BatchUpdateOptions): Promise<void> {
13
+ const ids = options.ids.split(",").map((id) => parseInt(id.trim(), 10));
14
+
15
+ if (ids.length === 0 || ids.some(Number.isNaN)) {
16
+ outputError("--ids is required (comma-separated list)", 400);
17
+ }
18
+
19
+ if (!options.json) {
20
+ outputError("JSON patch data is required", 400);
21
+ }
22
+
23
+ const collectionId = options.collection ? parseInt(options.collection, 10) : 0;
24
+ const api = getAuthenticatedAPI(options);
25
+ const patchData: RaindropUpdate = JSON.parse(options.json);
26
+ const success = await withSpinner(`Updating ${ids.length} bookmark(s)...`, () =>
27
+ api.batchUpdateRaindrops(collectionId, ids, patchData),
28
+ );
29
+ output({ success }, options.format);
30
+ }
31
+
32
+ export interface BatchDeleteOptions extends GlobalOptions {
33
+ ids: string;
34
+ collection?: string;
35
+ }
36
+
37
+ export async function cmdBatchDelete(options: BatchDeleteOptions): Promise<void> {
38
+ const ids = options.ids.split(",").map((id) => parseInt(id.trim(), 10));
39
+
40
+ if (ids.length === 0 || ids.some(Number.isNaN)) {
41
+ outputError("--ids is required (comma-separated list)", 400);
42
+ }
43
+
44
+ const collectionId = options.collection ? parseInt(options.collection, 10) : 0;
45
+ const api = getAuthenticatedAPI(options);
46
+ const success = await withSpinner(`Deleting ${ids.length} bookmark(s)...`, () =>
47
+ api.batchDeleteRaindrops(collectionId, ids),
48
+ );
49
+ output({ success }, options.format);
50
+ }
@@ -0,0 +1,257 @@
1
+ import type { CollectionCreate, CollectionUpdate } from "../api";
2
+ import { type GlobalOptions, output, outputError } from "../utils/output";
3
+ import { startSpinner, stopSpinner, withSpinner } from "../utils/spinner";
4
+ import { getTempFilePath } from "../utils/tempfile";
5
+ import { getAuthenticatedAPI } from "./auth";
6
+
7
+ export interface CollectionCreateOptions extends GlobalOptions {
8
+ title: string;
9
+ parent?: string;
10
+ public?: boolean;
11
+ private?: boolean;
12
+ view?: string;
13
+ }
14
+
15
+ export async function cmdCollectionCreate(options: CollectionCreateOptions): Promise<void> {
16
+ if (!options.title) {
17
+ outputError("Collection title is required", 400);
18
+ }
19
+
20
+ const api = getAuthenticatedAPI(options);
21
+ const parentId = options.parent ? parseInt(options.parent, 10) : undefined;
22
+ const isPublic = options.public ? true : options.private ? false : undefined;
23
+
24
+ const collection: CollectionCreate = {
25
+ title: options.title,
26
+ parent: parentId ? { $id: parentId } : undefined,
27
+ public: isPublic,
28
+ view: options.view as CollectionCreate["view"],
29
+ };
30
+ const result = await withSpinner("Creating collection...", () => api.createCollection(collection));
31
+ output(result, options.format);
32
+ }
33
+
34
+ export interface CollectionGetOptions extends GlobalOptions {
35
+ id: string;
36
+ }
37
+
38
+ export async function cmdCollectionGet(options: CollectionGetOptions): Promise<void> {
39
+ const id = parseInt(options.id, 10);
40
+
41
+ if (Number.isNaN(id)) {
42
+ outputError("Invalid collection ID", 400);
43
+ }
44
+
45
+ const api = getAuthenticatedAPI(options);
46
+ const result = await withSpinner("Fetching collection...", () => api.getCollection(id));
47
+ output(result, options.format);
48
+ }
49
+
50
+ export interface CollectionUpdateOptions extends GlobalOptions {
51
+ id: string;
52
+ json: string;
53
+ }
54
+
55
+ export async function cmdCollectionUpdate(options: CollectionUpdateOptions): Promise<void> {
56
+ const id = parseInt(options.id, 10);
57
+
58
+ if (Number.isNaN(id)) {
59
+ outputError("Invalid collection ID", 400);
60
+ }
61
+
62
+ if (!options.json) {
63
+ outputError("JSON patch data is required", 400);
64
+ }
65
+
66
+ const api = getAuthenticatedAPI(options);
67
+ const patchData: CollectionUpdate = JSON.parse(options.json);
68
+ const result = await withSpinner("Updating collection...", () => api.updateCollection(id, patchData));
69
+ output(result, options.format);
70
+ }
71
+
72
+ export interface CollectionDeleteOptions extends GlobalOptions {
73
+ id: string;
74
+ }
75
+
76
+ export async function cmdCollectionDelete(options: CollectionDeleteOptions): Promise<void> {
77
+ const id = parseInt(options.id, 10);
78
+
79
+ if (Number.isNaN(id)) {
80
+ outputError("Invalid collection ID", 400);
81
+ }
82
+
83
+ const api = getAuthenticatedAPI(options);
84
+ const success = await withSpinner("Deleting collection...", () => api.deleteCollection(id));
85
+ output({ success }, options.format);
86
+ }
87
+
88
+ export interface CollectionDeleteMultipleOptions extends GlobalOptions {
89
+ ids: string;
90
+ }
91
+
92
+ export async function cmdCollectionDeleteMultiple(options: CollectionDeleteMultipleOptions): Promise<void> {
93
+ if (!options.ids) {
94
+ outputError("Collection IDs are required (comma-separated)", 400);
95
+ }
96
+
97
+ const ids = options.ids.split(",").map((id) => parseInt(id.trim(), 10));
98
+ if (ids.some(Number.isNaN)) {
99
+ outputError("Invalid collection IDs", 400);
100
+ }
101
+
102
+ const api = getAuthenticatedAPI(options);
103
+ const success = await withSpinner(`Deleting ${ids.length} collections...`, () => api.deleteCollections(ids));
104
+ output({ success }, options.format);
105
+ }
106
+
107
+ export interface CollectionReorderOptions extends GlobalOptions {
108
+ sort: string;
109
+ }
110
+
111
+ export async function cmdCollectionReorder(options: CollectionReorderOptions): Promise<void> {
112
+ if (!options.sort) {
113
+ outputError("Sort order is required (title, -title, -count)", 400);
114
+ }
115
+
116
+ const api = getAuthenticatedAPI(options);
117
+ const success = await withSpinner("Reordering collections...", () => api.reorderCollections(options.sort));
118
+ output({ success }, options.format);
119
+ }
120
+
121
+ export interface CollectionExpandAllOptions extends GlobalOptions {
122
+ expanded: string;
123
+ }
124
+
125
+ export async function cmdCollectionExpandAll(options: CollectionExpandAllOptions): Promise<void> {
126
+ const expanded = options.expanded.toLowerCase() === "true";
127
+
128
+ const api = getAuthenticatedAPI(options);
129
+ const action = expanded ? "Expanding" : "Collapsing";
130
+ const success = await withSpinner(`${action} all collections...`, () => api.expandAllCollections(expanded));
131
+ output({ success }, options.format);
132
+ }
133
+
134
+ export interface CollectionMergeOptions extends GlobalOptions {
135
+ ids: string;
136
+ target: string;
137
+ }
138
+
139
+ export async function cmdCollectionMerge(options: CollectionMergeOptions): Promise<void> {
140
+ if (!options.ids) {
141
+ outputError("Source collection IDs are required (comma-separated)", 400);
142
+ }
143
+
144
+ const targetId = parseInt(options.target, 10);
145
+ if (Number.isNaN(targetId)) {
146
+ outputError("Target collection ID is required", 400);
147
+ }
148
+
149
+ const ids = options.ids.split(",").map((id) => parseInt(id.trim(), 10));
150
+ if (ids.some(Number.isNaN)) {
151
+ outputError("Invalid collection IDs", 400);
152
+ }
153
+
154
+ const api = getAuthenticatedAPI(options);
155
+ const success = await withSpinner("Merging collections...", () => api.mergeCollections(ids, targetId));
156
+ output({ success }, options.format);
157
+ }
158
+
159
+ export async function cmdCollectionClean(options: GlobalOptions): Promise<void> {
160
+ const api = getAuthenticatedAPI(options);
161
+ const count = await withSpinner("Cleaning empty collections...", () => api.cleanEmptyCollections());
162
+ output({ removed_count: count }, options.format);
163
+ }
164
+
165
+ export async function cmdCollectionEmptyTrash(options: GlobalOptions): Promise<void> {
166
+ const api = getAuthenticatedAPI(options);
167
+ const success = await withSpinner("Emptying trash...", () => api.emptyTrash());
168
+ output({ success }, options.format);
169
+ }
170
+
171
+ export interface CollectionCoverOptions extends GlobalOptions {
172
+ id: string;
173
+ source: string;
174
+ }
175
+
176
+ export async function cmdCollectionCover(options: CollectionCoverOptions): Promise<void> {
177
+ const id = parseInt(options.id, 10);
178
+
179
+ if (Number.isNaN(id)) {
180
+ outputError("Invalid collection ID", 400);
181
+ }
182
+
183
+ if (!options.source) {
184
+ outputError("Cover image path or URL is required", 400);
185
+ }
186
+
187
+ const api = getAuthenticatedAPI(options);
188
+ let filePath = options.source;
189
+ let isTemp = false;
190
+
191
+ if (options.source.startsWith("http://") || options.source.startsWith("https://")) {
192
+ const spinner = startSpinner("Downloading cover image...");
193
+ const response = await fetch(options.source);
194
+ if (!response.ok) {
195
+ stopSpinner(spinner, false);
196
+ outputError(`Failed to download image: ${response.status}`, response.status);
197
+ }
198
+ filePath = getTempFilePath("raindrop_cover", ".png");
199
+ await Bun.write(filePath, await response.arrayBuffer());
200
+ stopSpinner(spinner, true, "Downloaded");
201
+ isTemp = true;
202
+ }
203
+
204
+ const result = await withSpinner("Uploading cover...", () => api.uploadCollectionCover(id, filePath));
205
+
206
+ if (isTemp) {
207
+ await Bun.$`rm -f ${filePath}`;
208
+ }
209
+
210
+ output(result, options.format);
211
+ }
212
+
213
+ export interface CollectionSetIconOptions extends GlobalOptions {
214
+ id: string;
215
+ query: string;
216
+ }
217
+
218
+ export async function cmdCollectionSetIcon(options: CollectionSetIconOptions): Promise<void> {
219
+ const id = parseInt(options.id, 10);
220
+
221
+ if (Number.isNaN(id)) {
222
+ outputError("Invalid collection ID", 400);
223
+ }
224
+
225
+ if (!options.query) {
226
+ outputError("Icon search query is required", 400);
227
+ }
228
+
229
+ const api = getAuthenticatedAPI(options);
230
+ const icons = await withSpinner(`Searching icons for '${options.query}'...`, () => api.searchCovers(options.query));
231
+
232
+ if (icons.length === 0) {
233
+ outputError("No icons found", 404);
234
+ }
235
+
236
+ const iconUrl = icons[0];
237
+ if (!iconUrl) {
238
+ outputError("No icons found", 404);
239
+ }
240
+
241
+ const spinner = startSpinner("Downloading icon...");
242
+ const response = await fetch(iconUrl);
243
+ if (!response.ok) {
244
+ stopSpinner(spinner, false);
245
+ outputError(`Failed to download icon: ${response.status}`, response.status);
246
+ }
247
+
248
+ const filePath = getTempFilePath("raindrop_icon", ".png");
249
+ await Bun.write(filePath, await response.arrayBuffer());
250
+ stopSpinner(spinner, true, "Downloaded");
251
+
252
+ const result = await withSpinner("Uploading icon...", () => api.uploadCollectionCover(id, filePath));
253
+
254
+ await Bun.$`rm -f ${filePath}`;
255
+
256
+ output(result, options.format);
257
+ }
@@ -0,0 +1,27 @@
1
+ export { cmdLogin, cmdLogout, cmdWhoami, getAuthenticatedAPI } from "./auth";
2
+ export { cmdBatchDelete, cmdBatchUpdate } from "./batch";
3
+ export {
4
+ cmdCollectionClean,
5
+ cmdCollectionCover,
6
+ cmdCollectionCreate,
7
+ cmdCollectionDelete,
8
+ cmdCollectionDeleteMultiple,
9
+ cmdCollectionEmptyTrash,
10
+ cmdCollectionExpandAll,
11
+ cmdCollectionGet,
12
+ cmdCollectionMerge,
13
+ cmdCollectionReorder,
14
+ cmdCollectionSetIcon,
15
+ cmdCollectionUpdate,
16
+ } from "./collections";
17
+ export { cmdContext, cmdSchema, cmdStructure } from "./overview";
18
+ export {
19
+ cmdAdd,
20
+ cmdDelete,
21
+ cmdGet,
22
+ cmdPatch,
23
+ cmdSearch,
24
+ cmdSuggest,
25
+ cmdWayback,
26
+ } from "./raindrops";
27
+ export { cmdTagDelete, cmdTagRename } from "./tags";
@@ -0,0 +1,95 @@
1
+ import { type GlobalOptions, output } from "../utils/output";
2
+ import { withSpinner } from "../utils/spinner";
3
+ import { getAuthenticatedAPI } from "./auth";
4
+
5
+ export async function cmdContext(options: GlobalOptions): Promise<void> {
6
+ const api = getAuthenticatedAPI(options);
7
+
8
+ const [user, stats, recent, collections] = await withSpinner("Loading account context...", () =>
9
+ Promise.all([api.getUser(), api.getStats(), api.search("", 0, 5), api.getCollections()]),
10
+ );
11
+
12
+ const totalBookmarks = stats.find((s) => s._id === 0)?.count ?? 0;
13
+
14
+ const contextData = {
15
+ user: [{ id: user._id, name: user.fullName }],
16
+ stats: [
17
+ {
18
+ total_bookmarks: totalBookmarks,
19
+ total_collections: collections.length,
20
+ },
21
+ ],
22
+ structure: {
23
+ root_collections: collections
24
+ .filter((c) => !c.parent)
25
+ .map((c) => ({ id: c._id, title: c.title, count: c.count })),
26
+ },
27
+ recent_activity: recent.slice(0, 5).map((r) => ({
28
+ id: r._id,
29
+ title: r.title,
30
+ created: r.created,
31
+ })),
32
+ };
33
+
34
+ output(contextData, options.format);
35
+ }
36
+
37
+ export async function cmdStructure(options: GlobalOptions): Promise<void> {
38
+ const api = getAuthenticatedAPI(options);
39
+
40
+ const [collections, tags] = await withSpinner("Loading structure...", () =>
41
+ Promise.all([api.getCollections(), api.getTags()]),
42
+ );
43
+
44
+ output(
45
+ {
46
+ collections: collections.map((c) => ({
47
+ id: c._id,
48
+ title: c.title,
49
+ count: c.count,
50
+ parent_id: c.parent?.$id ?? null,
51
+ last_update: c.lastUpdate,
52
+ })),
53
+ tags: tags.map((t) => t._id),
54
+ },
55
+ options.format,
56
+ );
57
+ }
58
+
59
+ export function cmdSchema(): void {
60
+ const schemas = {
61
+ Raindrop: {
62
+ type: "object",
63
+ properties: {
64
+ link: { type: "string", required: true },
65
+ title: { type: "string" },
66
+ excerpt: { type: "string" },
67
+ tags: { type: "array", items: { type: "string" } },
68
+ collection: { type: "object", properties: { $id: { type: "number" } } },
69
+ important: { type: "boolean" },
70
+ cover: { type: "string" },
71
+ note: { type: "string" },
72
+ },
73
+ },
74
+ Collection: {
75
+ type: "object",
76
+ properties: {
77
+ title: { type: "string", required: true },
78
+ parent: { type: "object", properties: { $id: { type: "number" } } },
79
+ public: { type: "boolean" },
80
+ view: { type: "string", enum: ["list", "simple", "grid", "masonry"] },
81
+ },
82
+ },
83
+ };
84
+
85
+ const usageExamples = {
86
+ patch_update_title_tags: 'raindrop patch <id> \'{"title": "New Title", "tags": ["ai", "cli"]}\'',
87
+ move_single_bookmark: 'raindrop patch <id> \'{"collection": {"$id": <target_col_id>}}\'',
88
+ move_batch_bookmarks:
89
+ 'raindrop batch update --ids 1,2 --collection <source_col_id> \'{"collection": {"$id": <target_col_id>}}\'',
90
+ create_collection: 'raindrop collection create "Research" --public',
91
+ search_with_tags: 'raindrop search "python tag:important"',
92
+ };
93
+
94
+ console.log(JSON.stringify({ schemas, usage_examples: usageExamples }, null, 2));
95
+ }