1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
#!/usr/bin/env ruby
# frozen_string_literal: true

# Upload extracted Akkoma bookmarks to Raindrop.io
#
# Prerequisites:
#   1. Create a Raindrop.io app at https://app.raindrop.io/settings/integrations
#   2. Generate a "test token" (personal access token)
#   3. Set RAINDROP_TOKEN env var, or create a .raindrop_token file
#
# Usage:
#   RAINDROP_TOKEN=xxx ruby upload_to_raindrop.rb [--dry-run] [--collection NAME]
#
# Each bookmark becomes a Raindrop entry linking to the original fedi post.
# The post content is stored as the excerpt, tags are preserved,
# and any URLs from the post content are listed in a note.

require "json"
require "net/http"
require "uri"
require "time"

BOOKMARKS_FILE = File.join(__dir__, "bookmarks.json")

# ── Config ──────────────────────────────────────────────────────────────────

def raindrop_token
  ENV["RAINDROP_TOKEN"] ||
    (File.read(File.join(__dir__, ".raindrop_token")).strip rescue nil) ||
    abort("Set RAINDROP_TOKEN env var or create .raindrop_token file")
end

DRY_RUN = ARGV.include?("--dry-run")
COLLECTION_NAME = if (idx = ARGV.index("--collection"))
  ARGV[idx + 1] || abort("--collection requires a name")
else
  "Akkoma Bookmarks"
end

# ── Raindrop API helpers ────────────────────────────────────────────────────

API_BASE = URI("https://api.raindrop.io/rest/v1")

def api_request(method, path, body: nil)
  uri = URI("#{API_BASE}#{path}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  req = case method
  when :get    then Net::HTTP::Get.new(uri)
  when :post   then Net::HTTP::Post.new(uri)
  when :put    then Net::HTTP::Put.new(uri)
  end

  req["Authorization"] = "Bearer #{raindrop_token}"
  req["Content-Type"] = "application/json"

  if body
    req.body = JSON.generate(body)
  end

  response = http.request(req)

  unless response.is_a?(Net::HTTPSuccess)
    warn "API error #{response.code}: #{response.body}"
    return nil
  end

  JSON.parse(response.body)
end

# ── Find or create collection ───────────────────────────────────────────────

def find_or_create_collection(name)
  # List root collections
  result = api_request(:get, "/collections")
  return nil unless result

  existing = result["items"]&.find { |c| c["title"] == name }
  return existing["_id"] if existing

  # Create it
  result = api_request(:post, "/collection", body: {
    title: name,
    sort: 0,         # manual sort
    public: false,
    view: "list",
  })

  result&.dig("item", "_id")
end

# ── Build raindrop payload ──────────────────────────────────────────────────

def build_raindrop(bookmark, collection_id)
  link = bookmark["post_url"]

  note = bookmark["content_text"]

  tags = bookmark["tags"].dup
  tags << "akkoma-bookmark"  # easy to find them all later

  created = Time.parse(bookmark["bookmarked_at"]).utc.iso8601

  {
    link: link,
    note: note,
    tags: tags,
    collection: { "$id" => collection_id },
    created: created,
    pleaseParse: {},  # ask Raindrop to fetch metadata/favicon
  }
end

# ── Main ────────────────────────────────────────────────────────────────────

bookmarks = JSON.parse(File.read(BOOKMARKS_FILE))
puts "Loaded #{bookmarks.size} bookmarks from #{BOOKMARKS_FILE}"

if DRY_RUN
  puts "[DRY RUN] Would upload #{bookmarks.size} bookmarks to collection '#{COLLECTION_NAME}'"
  bookmarks.each_with_index do |bm, i|
    puts "  #{i + 1}. #{bm["post_url"]}"
    puts "     #{bm["content_text"][0, 80]}..."
  end
  exit
end

collection_id = find_or_create_collection(COLLECTION_NAME)
abort "Failed to find/create collection '#{COLLECTION_NAME}'" unless collection_id
puts "Using collection '#{COLLECTION_NAME}' (id: #{collection_id})"

# Upload in batches — Raindrop supports bulk create via POST /rest/v1/raindrops
# (up to 100 items per request)
created = 0
failed = 0

bookmarks.each_slice(100).with_index do |batch, batch_idx|
  items = batch.map { |bm| build_raindrop(bm, collection_id) }

  puts "Uploading batch #{batch_idx + 1} (#{items.size} items)…"

  result = api_request(:post, "/raindrops", body: { items: items })

  if result && result["items"]
    created += result["items"].size
    puts "  ✓ created #{result["items"].size} raindrops"
  else
    failed += batch.size
    puts "  ✗ batch failed"
  end

  sleep 1  # be polite to the API
end

puts
puts "Done! Created #{created} raindrops, #{failed} failed."