Don't forget to lycansubscribe

add an importer from quotes and pins (bookmarks)

+74
app/importers/posts_importer.rb
···
··· 1 + require 'time' 2 + 3 + require_relative '../at_uri' 4 + require_relative 'base_importer' 5 + 6 + class PostsImporter < BaseImporter 7 + def import_items 8 + params = { repo: @did, collection: 'app.bsky.feed.post', limit: 100 } 9 + params[:cursor] = @import.cursor if @import.cursor 10 + 11 + loop do 12 + response = @minisky.get_request('com.atproto.repo.listRecords', params) 13 + 14 + records = response['records'] 15 + cursor = response['cursor'] 16 + 17 + @imported_count += records.length 18 + @report&.update(importers: { importer_name => { :imported_items => @imported_count }}) 19 + @report&.update(importers: { importer_name => { :oldest_date => Time.parse(records.last['value']['createdAt']) }}) unless records.empty? 20 + 21 + records.each do |record| 22 + begin 23 + if record['value']['embed'] && record['value']['embed']['record'] 24 + save_quote(record) 25 + end 26 + 27 + if record['value']['reply'] && record['value']['text'].include?('📌') 28 + save_pin(record) 29 + end 30 + rescue StandardError => e 31 + puts "Error in LikesImporter: #{record['uri']}: #{e}" 32 + end 33 + end 34 + 35 + params[:cursor] = cursor 36 + @import.update!(cursor: cursor) 37 + 38 + break if !cursor 39 + break if @time_limit && records.any? { |x| Time.parse(x['value']['createdAt']) < @time_limit } 40 + end 41 + end 42 + 43 + def save_quote(record) 44 + post_rkey = AT_URI(record['uri']).rkey 45 + return if @user.quotes.where(rkey: post_rkey).exists? 46 + 47 + post_time = Time.parse(record['value']['createdAt']) 48 + 49 + quoted_post_uri = case record['value']['embed']['$type'] 50 + when 'app.bsky.embed.record' 51 + record['value']['embed']['record']['uri'] 52 + when 'app.bsky.embed.recordWithMedia' 53 + record['value']['embed']['record']['record']['uri'] 54 + else 55 + return 56 + end 57 + 58 + create_item_for_post(quoted_post_uri) do |args| 59 + @user.quotes.create!(args.merge(rkey: post_rkey, time: post_time, quote_text: record['value']['text'])) 60 + end 61 + end 62 + 63 + def save_pin(record) 64 + post_rkey = AT_URI(record['uri']).rkey 65 + return if @user.pins.where(rkey: post_rkey).exists? 66 + 67 + post_time = Time.parse(record['value']['createdAt']) 68 + parent_post_uri = record['value']['reply']['parent']['uri'] 69 + 70 + create_item_for_post(parent_post_uri) do |args| 71 + @user.pins.create!(args.merge(rkey: post_rkey, time: post_time, pin_text: record['value']['text'])) 72 + end 73 + end 74 + end
+1 -1
app/models/import.rb
··· 5 class Import < ActiveRecord::Base 6 belongs_to :user 7 8 - validates_inclusion_of :collection, in: %w(likes reposts) 9 validates_uniqueness_of :collection, scope: :user_id 10 end
··· 5 class Import < ActiveRecord::Base 6 belongs_to :user 7 8 + validates_inclusion_of :collection, in: %w(likes reposts posts) 9 validates_uniqueness_of :collection, scope: :user_id 10 end
+18
app/models/pin.rb
···
··· 1 + require 'active_record' 2 + 3 + require_relative 'post' 4 + require_relative 'searchable' 5 + require_relative 'user' 6 + 7 + class Pin < ActiveRecord::Base 8 + include Searchable 9 + 10 + validates_presence_of :time, :rkey 11 + validates_length_of :rkey, maximum: 13 12 + validates :pin_text, length: { minimum: 0, maximum: 1000, allow_nil: false } 13 + 14 + validates_presence_of :post_uri, if: -> { post_id.nil? } 15 + 16 + belongs_to :user, foreign_key: 'actor_id' 17 + belongs_to :post, optional: true 18 + end
+18
app/models/quote.rb
···
··· 1 + require 'active_record' 2 + 3 + require_relative 'post' 4 + require_relative 'searchable' 5 + require_relative 'user' 6 + 7 + class Quote < ActiveRecord::Base 8 + include Searchable 9 + 10 + validates_presence_of :time, :rkey 11 + validates_length_of :rkey, maximum: 13 12 + validates :quote_text, length: { minimum: 0, maximum: 1000, allow_nil: false } 13 + 14 + validates_presence_of :post_uri, if: -> { post_id.nil? } 15 + 16 + belongs_to :user, foreign_key: 'actor_id' 17 + belongs_to :post, optional: true 18 + end
+4
app/models/user.rb
··· 2 3 require_relative 'import' 4 require_relative 'like' 5 require_relative 'post' 6 require_relative 'repost' 7 ··· 12 has_many :posts 13 has_many :likes, foreign_key: 'actor_id' 14 has_many :reposts, foreign_key: 'actor_id' 15 has_many :imports 16 end
··· 2 3 require_relative 'import' 4 require_relative 'like' 5 + require_relative 'quote' 6 + require_relative 'pin' 7 require_relative 'post' 8 require_relative 'repost' 9 ··· 14 has_many :posts 15 has_many :likes, foreign_key: 'actor_id' 16 has_many :reposts, foreign_key: 'actor_id' 17 + has_many :quotes, foreign_key: 'actor_id' 18 + has_many :pins, foreign_key: 'actor_id' 19 has_many :imports 20 end
+8 -1
app/server.rb
··· 113 114 collection = case params[:collection] 115 when 'likes' then user.likes 116 when 'reposts' then user.reposts 117 else return json_error('InvalidParameter', 'Invalid search collection') 118 end ··· 125 .after_cursor(params[:cursor]) 126 .limit(PAGE_LIMIT) 127 128 - post_uris = items.map(&:post).map(&:at_uri) 129 130 json_response(posts: post_uris, cursor: items.last&.cursor) 131 end
··· 113 114 collection = case params[:collection] 115 when 'likes' then user.likes 116 + when 'pins' then user.pins 117 + when 'quotes' then user.quotes 118 when 'reposts' then user.reposts 119 else return json_error('InvalidParameter', 'Invalid search collection') 120 end ··· 127 .after_cursor(params[:cursor]) 128 .limit(PAGE_LIMIT) 129 130 + post_uris = case params[:collection] 131 + when 'quotes' 132 + items.map { |x| "at://#{user.did}/app.bsky.feed.post/#{x.rkey}" } 133 + else 134 + items.map(&:post).map(&:at_uri) 135 + end 136 137 json_response(posts: post_uris, cursor: items.last&.cursor) 138 end
+27
db/migrate/20250906233017_add_quotes_and_pins.rb
···
··· 1 + class AddQuotesAndPins < ActiveRecord::Migration[7.2] 2 + def change 3 + create_table :quotes do |t| 4 + t.integer "actor_id", null: false 5 + t.string "rkey", limit: 13, null: false 6 + t.datetime "time", null: false 7 + t.text "quote_text", null: false 8 + t.bigint "post_id" 9 + t.string "post_uri" 10 + end 11 + 12 + add_index :quotes, [:actor_id, :time, :id], order: { time: :desc, id: :desc } 13 + add_index :quotes, [:actor_id, :rkey], unique: true 14 + 15 + create_table :pins do |t| 16 + t.integer "actor_id", null: false 17 + t.string "rkey", limit: 13, null: false 18 + t.datetime "time", null: false 19 + t.text "pin_text", null: false 20 + t.bigint "post_id" 21 + t.string "post_uri" 22 + end 23 + 24 + add_index :pins, [:actor_id, :time, :id], order: { time: :desc, id: :desc } 25 + add_index :pins, [:actor_id, :rkey], unique: true 26 + end 27 + end
+23 -1
db/schema.rb
··· 10 # 11 # It's strongly recommended that you check this file into your version control system. 12 13 - ActiveRecord::Schema[7.2].define(version: 2025_09_06_005748) do 14 # These are extensions that must be enabled in order to support this database 15 enable_extension "plpgsql" 16 ··· 33 t.index ["actor_id", "time", "id"], name: "index_likes_on_actor_id_and_time_and_id", order: { time: :desc, id: :desc } 34 end 35 36 create_table "posts", force: :cascade do |t| 37 t.integer "user_id", null: false 38 t.string "rkey", limit: 13, null: false ··· 41 t.text "data", null: false 42 t.index ["user_id", "rkey"], name: "index_posts_on_user_id_and_rkey", unique: true 43 t.index ["user_id", "time", "id"], name: "index_posts_on_user_id_and_time_and_id", order: { time: :desc, id: :desc } 44 end 45 46 create_table "reposts", force: :cascade do |t|
··· 10 # 11 # It's strongly recommended that you check this file into your version control system. 12 13 + ActiveRecord::Schema[7.2].define(version: 2025_09_06_233017) do 14 # These are extensions that must be enabled in order to support this database 15 enable_extension "plpgsql" 16 ··· 33 t.index ["actor_id", "time", "id"], name: "index_likes_on_actor_id_and_time_and_id", order: { time: :desc, id: :desc } 34 end 35 36 + create_table "pins", force: :cascade do |t| 37 + t.integer "actor_id", null: false 38 + t.string "rkey", limit: 13, null: false 39 + t.datetime "time", null: false 40 + t.text "pin_text", null: false 41 + t.bigint "post_id" 42 + t.string "post_uri" 43 + t.index ["actor_id", "rkey"], name: "index_pins_on_actor_id_and_rkey", unique: true 44 + t.index ["actor_id", "time", "id"], name: "index_pins_on_actor_id_and_time_and_id", order: { time: :desc, id: :desc } 45 + end 46 + 47 create_table "posts", force: :cascade do |t| 48 t.integer "user_id", null: false 49 t.string "rkey", limit: 13, null: false ··· 52 t.text "data", null: false 53 t.index ["user_id", "rkey"], name: "index_posts_on_user_id_and_rkey", unique: true 54 t.index ["user_id", "time", "id"], name: "index_posts_on_user_id_and_time_and_id", order: { time: :desc, id: :desc } 55 + end 56 + 57 + create_table "quotes", force: :cascade do |t| 58 + t.integer "actor_id", null: false 59 + t.string "rkey", limit: 13, null: false 60 + t.datetime "time", null: false 61 + t.text "quote_text", null: false 62 + t.bigint "post_id" 63 + t.string "post_uri" 64 + t.index ["actor_id", "rkey"], name: "index_quotes_on_actor_id_and_rkey", unique: true 65 + t.index ["actor_id", "time", "id"], name: "index_quotes_on_actor_id_and_time_and_id", order: { time: :desc, id: :desc } 66 end 67 68 create_table "reposts", force: :cascade do |t|
+5
lib/tasks/import.rake
··· 1 require_relative '../../app/importers/likes_importer' 2 require_relative '../../app/importers/reposts_importer' 3 require_relative '../../app/import_report' 4 require_relative '../../app/item_queue' 5 require_relative '../../app/post_downloader' ··· 18 when 'reposts' 19 queue = ItemQueue.new(user.reposts.where(post: nil).to_a) 20 importer = RepostsImporter.new(ENV['USER']) 21 when nil 22 raise "Required COLLECTION parameter missing" 23 else
··· 1 require_relative '../../app/importers/likes_importer' 2 + require_relative '../../app/importers/posts_importer' 3 require_relative '../../app/importers/reposts_importer' 4 + 5 require_relative '../../app/import_report' 6 require_relative '../../app/item_queue' 7 require_relative '../../app/post_downloader' ··· 20 when 'reposts' 21 queue = ItemQueue.new(user.reposts.where(post: nil).to_a) 22 importer = RepostsImporter.new(ENV['USER']) 23 + when 'posts' 24 + queue = ItemQueue.new(user.quotes.where(post: nil).to_a + user.pins.where(post: nil).to_a) 25 + importer = PostsImporter.new(ENV['USER']) 26 when nil 27 raise "Required COLLECTION parameter missing" 28 else