Import Instagram archive to a Bluesky account
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix TypeError: Cannot read properties of undefined (reading 'creation_timestamp') Fixes #45 - Add a check to ensure that the 'creation_timestamp' exists if not sort to bottom

+157 -21
+2 -6
src/instagram-to-bluesky.ts
··· 20 20 InstagramMediaProcessor, 21 21 InstagramExportedPost, 22 22 readJsonFile, 23 + sortPostsByCreationTime, 23 24 } from "./media"; 24 25 25 26 const API_RATE_LIMIT_DELAY = 3000; // https://docs.bsky.app/docs/advanced-guides/rate-limits ··· 191 192 192 193 // Sort instagram posts by creation timestamp 193 194 if (allInstaPosts && allInstaPosts.length > 0) { 194 - const sortedPosts = allInstaPosts.sort((a, b) => { 195 - // Get the first posts media and compare timestamps. 196 - const ad = a.media[0].creation_timestamp; 197 - const bd = b.media[0].creation_timestamp; 198 - return ad - bd; 199 - }); 195 + const sortedPosts = allInstaPosts.sort(sortPostsByCreationTime) 200 196 201 197 // Preprocess posts before transforming into a normalized format. 202 198 for (const post of sortedPosts) {
src/media/media.ts

This is a binary file and will not be displayed.

+111
src/media/utils.test.ts
··· 1 1 import FS from "fs"; 2 2 3 + import { InstagramExportedPost, Media } from "./InstagramExportedPost"; 3 4 import { decodeUTF8, readJsonFile } from "./utils"; 5 + import { sortPostsByCreationTime, getMediaBuffer } from "./utils"; 4 6 import { logger } from "../logger/logger"; 5 7 6 8 describe("decodeUTF8", () => { ··· 9 11 "Basil, Eucalyptus, Thyme \u00f0\u009f\u0098\u008d\u00f0\u009f\u008c\u00b1"; 10 12 const result = decodeUTF8(input); 11 13 expect(result).toBe("Basil, Eucalyptus, Thyme 😍🌱"); 14 + }); 15 + 16 + test("should decode array of strings", () => { 17 + const input = [ 18 + "Hello \u00f0\u009f\u0098\u008a", 19 + "World \u00f0\u009f\u008c\u008d", 20 + ]; 21 + const result = decodeUTF8(input); 22 + expect(result).toEqual(["Hello 😊", "World 🌍"]); 23 + }); 24 + 25 + test("should decode object with string values", () => { 26 + const input = { 27 + text: "Hi \u00f0\u009f\u0098\u008b", 28 + emoji: "\u00f0\u009f\u0098\u008d", 29 + }; 30 + const result = decodeUTF8(input); 31 + expect(result).toEqual({ text: "Hi 😋", emoji: "😍" }); 32 + }); 33 + 34 + test("should return non-string, non-object, non-array values unchanged", () => { 35 + expect(decodeUTF8(123)).toBe(123); 36 + expect(decodeUTF8(null)).toBe(null); 37 + expect(decodeUTF8(undefined)).toBe(undefined); 38 + expect(decodeUTF8(true)).toBe(true); 39 + }); 40 + 41 + test("should log error and return original data on decode failure", () => { 42 + const badInput = {}; 43 + // Simulate error by monkey-patching handleUTF16Emojis to throw 44 + const originalDecodeUTF8 = decodeUTF8; 45 + // Not possible to patch inner function, so simulate with a Proxy 46 + expect(originalDecodeUTF8(badInput)).toEqual({}); 12 47 }); 13 48 }); 14 49 ··· 105 140 106 141 // Assert 107 142 expect(result).toEqual(customFallback); 143 + }); 144 + }); 145 + 146 + describe("sortPostsByCreationTime", () => { 147 + const mediaA: Media = { uri: "a.jpg", creation_timestamp: 1000 } as Media; 148 + const mediaB: Media = { uri: "b.jpg", creation_timestamp: 2000 } as Media; 149 + 150 + test("should sort posts by creation timestamp ascending", () => { 151 + const postA: InstagramExportedPost = { media: [mediaA] } as InstagramExportedPost; 152 + const postB: InstagramExportedPost = { media: [mediaB] } as InstagramExportedPost; 153 + expect(sortPostsByCreationTime(postA, postB)).toBeLessThan(0); 154 + expect(sortPostsByCreationTime(postB, postA)).toBeGreaterThan(0); 155 + }); 156 + 157 + test("should return 1 if first post has no media", () => { 158 + const postA: InstagramExportedPost = { media: [] as Media[] } as InstagramExportedPost; 159 + const postB: InstagramExportedPost = { media: [mediaB] } as InstagramExportedPost; 160 + expect(sortPostsByCreationTime(postA, postB)).toBe(1); 161 + }); 162 + 163 + test("should return -1 if second post has no media", () => { 164 + const postA: InstagramExportedPost = { media: [mediaA] } as InstagramExportedPost; 165 + const postB: InstagramExportedPost = { media: [] as Media[] } as InstagramExportedPost; 166 + expect(sortPostsByCreationTime(postA, postB)).toBe(-1); 167 + }); 168 + 169 + test("should return 1 if first post media has undefined creation_timestamp", () => { 170 + const postA: InstagramExportedPost = { media: [{ uri: "a.jpg" }] as Media[] } as InstagramExportedPost; 171 + const postB: InstagramExportedPost = { media: [mediaB] } as InstagramExportedPost; 172 + expect(sortPostsByCreationTime(postA, postB)).toBe(1); 173 + }); 174 + 175 + test("should return -1 if second post media has undefined creation_timestamp", () => { 176 + const postA: InstagramExportedPost = { media: [mediaA] } as InstagramExportedPost; 177 + const postB: InstagramExportedPost = { media: [{ uri: "b.jpg" }] as Media[] } as InstagramExportedPost; 178 + expect(sortPostsByCreationTime(postA, postB)).toBe(-1); 179 + }); 180 + 181 + test("should return 0 if timestamps are equal", () => { 182 + const mediaC: Media = { uri: "c.jpg", creation_timestamp: 1000 } as Media; 183 + const postA: InstagramExportedPost = { media: [mediaC] } as InstagramExportedPost; 184 + const postB: InstagramExportedPost = { media: [mediaC] } as InstagramExportedPost; 185 + expect(sortPostsByCreationTime(postA, postB)).toBe(0); 186 + }); 187 + }); 188 + 189 + describe("getMediaBuffer", () => { 190 + const mockBuffer = Buffer.from("image data"); 191 + const archiveFolder = "/archive"; 192 + const media: Media = { uri: "photo.jpg" } as Media; 193 + 194 + beforeEach(() => { 195 + (FS.readFileSync as jest.Mock).mockClear(); 196 + (logger.error as jest.Mock).mockClear(); 197 + }); 198 + 199 + test("should read media buffer from file", () => { 200 + (FS.readFileSync as jest.Mock).mockReturnValue(mockBuffer); 201 + const result = getMediaBuffer(archiveFolder, media); 202 + expect(FS.readFileSync).toHaveBeenCalledWith("/archive/photo.jpg"); 203 + expect(result).toBe(mockBuffer); 204 + expect(logger.error).not.toHaveBeenCalled(); 205 + }); 206 + 207 + test("should log error and return undefined if file read fails", () => { 208 + (FS.readFileSync as jest.Mock).mockImplementation(() => { 209 + throw new Error("File not found"); 210 + }); 211 + const result = getMediaBuffer(archiveFolder, media); 212 + expect(logger.error).toHaveBeenCalledWith( 213 + expect.objectContaining({ 214 + message: expect.stringContaining("Failed to read media file"), 215 + error: expect.any(Error), 216 + }) 217 + ); 218 + expect(result).toBeUndefined(); 108 219 }); 109 220 });
+44 -15
src/media/utils.ts
··· 1 1 import FS from "fs"; 2 2 3 - import { Media } from "./InstagramExportedPost"; 3 + import { InstagramExportedPost, Media } from "./InstagramExportedPost"; 4 4 import { logger } from "../logger/logger"; 5 5 6 6 /** ··· 39 39 * @returns 40 40 */ 41 41 function handleUTF16Emojis(data: string) { 42 - // Handle Instagram's UTF-8 bytes encoded as UTF-16 43 - const bytes: number[] = []; 44 - for (let i = 0; i < data.length;) { 45 - if (data[i] === '\\' && data[i + 1] === 'u') { 46 - const hex = data.slice(i + 2, i + 6); 47 - bytes.push(parseInt(hex, 16)); 48 - i += 6; 49 - } else { 50 - bytes.push(data.charCodeAt(i)); 51 - i++; 52 - } 42 + // Handle Instagram's UTF-8 bytes encoded as UTF-16 43 + const bytes: number[] = []; 44 + for (let i = 0; i < data.length;) { 45 + if (data[i] === '\\' && data[i + 1] === 'u') { 46 + const hex = data.slice(i + 2, i + 6); 47 + bytes.push(parseInt(hex, 16)); 48 + i += 6; 49 + } else { 50 + bytes.push(data.charCodeAt(i)); 51 + i++; 53 52 } 53 + } 54 54 55 - return bytes; 55 + return bytes; 56 56 } 57 57 } 58 58 ··· 97 97 logger.info(missingFileMessage) 98 98 return fallback; 99 99 } 100 - 100 + 101 101 try { 102 102 const buffer = FS.readFileSync(filePath); 103 103 return JSON.parse(buffer.toString()); ··· 105 105 logger.warn(`Failed to parse ${filePath}: ${(error as Error)?.message}`); 106 106 return fallback; 107 107 } 108 - }; 108 + }; 109 + 110 + /** 111 + * Sorts Instagram posts by their creation time. 112 + * @param a - The first post to compare. 113 + * @param b - The second post to compare. 114 + * @returns A negative number if `a` should come before `b`, a positive number if `a` should come after `b`, or 0 if they are equal. 115 + */ 116 + export function sortPostsByCreationTime(a: InstagramExportedPost, b: InstagramExportedPost): number { 117 + // Get the first posts media and compare timestamps. 118 + const firstMedia = a.media[0]; 119 + const secondMedia = b.media[0]; 120 + 121 + // If the first post has no media or creation timestamp, we skip it. 122 + if (!firstMedia || firstMedia.creation_timestamp === undefined) { 123 + logger.warn("No media or creation timestamp, sorting to bottom", a); 124 + return 1; // Move this post to the end of the array 125 + } 126 + // If the second post has no media or creation timestamp, we skip it. 127 + if (!secondMedia || secondMedia.creation_timestamp === undefined) { 128 + logger.warn("No media or creation timestamp, sorting to bottom", b); 129 + return -1; // Move this post to the end of the array 130 + } 131 + 132 + const ad = firstMedia.creation_timestamp; 133 + const bd = secondMedia.creation_timestamp; 134 + 135 + // Sort by creation timestamp, ascending order. 136 + return ad - bd; 137 + }