imessage-parser
Version:
Parse iMessage chat.db attributedBody NSAttributedString format in Node.js
436 lines • 19.1 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const commander_1 = require("commander");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const chalk_1 = __importDefault(require("chalk"));
const imessage_database_1 = require("./imessage-database");
const program = new commander_1.Command();
program
.name('imessage-parser')
.description('Parse and extract iMessage data from chat.db')
.version('0.1.0');
// List chats command
program
.command('list-chats')
.description('List all available chats')
.option('-l, --limit <number>', 'limit number of chats', '50')
.action(async (options) => {
const db = new imessage_database_1.IMessageDatabase();
try {
const chats = await db.getChats();
const limit = parseInt(options.limit);
console.log(chalk_1.default.blue(`\nFound ${chats.length} chats:\n`));
chats.slice(0, limit).forEach((chat, index) => {
const name = chat.display_name || chat.chat_identifier;
console.log(`${chalk_1.default.yellow(index + 1)}. ${chalk_1.default.green(name)} (ID: ${chat.ROWID})`);
});
if (chats.length > limit) {
console.log(chalk_1.default.gray(`\n... and ${chats.length - limit} more`));
}
}
catch (error) {
console.error(chalk_1.default.red('Error:'), error);
}
finally {
await db.close();
}
});
// Extract messages command
program
.command('extract')
.description('Extract messages from a chat')
.argument('<chat>', 'chat name or ID')
.option('-l, --limit <number>', 'limit number of messages', '100')
.option('-o, --output <file>', 'output file (default: stdout)')
.option('-f, --format <format>', 'output format: text, json, markdown', 'text')
.option('--include-formatting', 'include formatting information')
.option('--after <date>', 'only messages after this date (YYYY-MM-DD)')
.option('--before <date>', 'only messages before this date (YYYY-MM-DD)')
.action(async (chatIdentifier, options) => {
const db = new imessage_database_1.IMessageDatabase();
try {
// Find the chat
const chats = await db.getChats();
const chat = chats.find(c => c.display_name === chatIdentifier ||
c.ROWID.toString() === chatIdentifier ||
c.chat_identifier.includes(chatIdentifier));
if (!chat) {
console.error(chalk_1.default.red(`Chat "${chatIdentifier}" not found`));
process.exit(1);
}
console.log(chalk_1.default.blue(`Extracting messages from: ${chat.display_name || chat.chat_identifier}`));
// Get messages
const messages = await db.getMessagesFromChat(chat.ROWID, parseInt(options.limit));
// Parse messages
const parsedMessages = messages.map(msg => ({
...msg,
parsed: db.parseMessage(msg),
}));
// Filter by date if specified
let filteredMessages = parsedMessages;
if (options.after || options.before) {
const afterDate = options.after ? new Date(options.after).getTime() : 0;
const beforeDate = options.before ? new Date(options.before).getTime() : Infinity;
filteredMessages = parsedMessages.filter(msg => {
const msgDate = (msg.date / 1000000000) + 978307200000;
return msgDate >= afterDate && msgDate <= beforeDate;
});
}
// Format output
let output = '';
switch (options.format) {
case 'json':
output = formatAsJSON(filteredMessages, options.includeFormatting);
break;
case 'markdown':
output = formatAsMarkdown(filteredMessages, chat.display_name || chat.chat_identifier, options.includeFormatting);
break;
default:
output = formatAsText(filteredMessages, options.includeFormatting);
}
// Output
if (options.output) {
fs.writeFileSync(options.output, output);
console.log(chalk_1.default.green(`\nExtracted ${filteredMessages.length} messages to ${options.output}`));
}
else {
console.log(output);
}
}
catch (error) {
console.error(chalk_1.default.red('Error:'), error);
}
finally {
await db.close();
}
});
// Search command
program
.command('search')
.description('Search messages across all chats')
.argument('<query>', 'search query')
.option('-l, --limit <number>', 'limit number of results', '50')
.option('--case-sensitive', 'case sensitive search')
.option('--regex', 'use regular expression')
.action(async (query, options) => {
const db = new imessage_database_1.IMessageDatabase();
try {
console.log(chalk_1.default.blue(`Searching for: "${query}"...\n`));
const results = await db.searchMessages(query, parseInt(options.limit));
if (results.length === 0) {
console.log(chalk_1.default.yellow('No messages found'));
return;
}
console.log(chalk_1.default.green(`Found ${results.length} messages:\n`));
results.forEach((msg, index) => {
const parsed = db.parseMessage(msg);
const date = new Date((msg.date / 1000000000) + 978307200000);
const preview = parsed.text.substring(0, 100).replace(/\n/g, ' ');
console.log(chalk_1.default.yellow(`${index + 1}. `) + chalk_1.default.gray(`[${date.toLocaleString()}]`));
console.log(` ${chalk_1.default.cyan(msg.chat_display_name || 'Unknown Chat')}`);
console.log(` ${msg.is_from_me ? 'Me' : msg.handle_id}: ${preview}${parsed.text.length > 100 ? '...' : ''}`);
// Show link
if (parsed.link) {
console.log(` ${chalk_1.default.gray('Link:')} ${chalk_1.default.blue(parsed.link)}`);
}
// Highlight search term
if (!options.regex) {
const regex = new RegExp(query, options.caseSensitive ? 'g' : 'gi');
const highlighted = preview.replace(regex, chalk_1.default.bgYellow.black('$&'));
console.log(` ${highlighted}`);
}
console.log();
});
}
catch (error) {
console.error(chalk_1.default.red('Error:'), error);
}
finally {
await db.close();
}
});
// Parse specific message command
program
.command('parse-message')
.description('Parse a specific message by ROWID')
.argument('<rowid>', 'message ROWID')
.option('--show-hex', 'show hex dump of attributedBody')
.option('--show-readable', 'show all readable text segments')
.action(async (rowId, options) => {
const sqlite3 = require('sqlite3');
const db = new sqlite3.Database(path.join(os.homedir(), 'Library', 'Messages', 'chat.db'), sqlite3.OPEN_READONLY);
const query = `
SELECT
ROWID,
text,
attributedBody,
datetime(date/1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime') as formatted_date,
cache_has_attachments
FROM message
WHERE ROWID = ?
`;
db.get(query, [rowId], async (err, row) => {
if (err) {
console.error(chalk_1.default.red('Error:'), err);
return;
}
if (!row) {
console.log(chalk_1.default.red(`Message with ROWID ${rowId} not found`));
return;
}
console.log(chalk_1.default.blue('\n=== Message Details ==='));
console.log(`${chalk_1.default.yellow('ROWID:')} ${row.ROWID}`);
console.log(`${chalk_1.default.yellow('Date:')} ${row.formatted_date}`);
console.log(`${chalk_1.default.yellow('Has plain text:')} ${!!row.text}`);
console.log(`${chalk_1.default.yellow('Has attributedBody:')} ${!!row.attributedBody}`);
console.log(`${chalk_1.default.yellow('Has attachments:')} ${!!row.cache_has_attachments}`);
if (row.text) {
console.log(chalk_1.default.blue('\n=== Plain Text ==='));
console.log(row.text);
}
if (row.attributedBody) {
console.log(chalk_1.default.blue('\n=== AttributedBody Analysis ==='));
console.log(`${chalk_1.default.yellow('Size:')} ${row.attributedBody.length} bytes`);
if (options.showHex) {
console.log(chalk_1.default.blue('\n=== Hex Dump (first 200 bytes) ==='));
const hex = row.attributedBody.slice(0, 200).toString('hex');
for (let i = 0; i < hex.length; i += 32) {
console.log(hex.substring(i, i + 32).match(/.{2}/g).join(' '));
}
}
// Parse with our parser
const { AttributedStringParser, TypedStreamParser } = require('./index');
const parser = new AttributedStringParser({ includeMetadata: true });
const result = parser.parse(row.attributedBody);
console.log(chalk_1.default.blue('\n=== Parsed Text ==='));
console.log(result.text || chalk_1.default.gray('(no text extracted)'));
if (result.link) {
console.log(chalk_1.default.blue('\n=== Message Link ==='));
console.log(result.link);
}
if (result.attributes && Object.keys(result.attributes).length > 0) {
console.log(chalk_1.default.blue('\n=== Formatting Attributes ==='));
console.log(JSON.stringify(result.attributes, null, 2));
}
if (options.showReadable) {
const typedStreamParser = new TypedStreamParser(row.attributedBody);
const readableTexts = typedStreamParser.extractReadableText();
if (readableTexts.length > 0) {
console.log(chalk_1.default.blue(`\n=== Readable Text Segments (${readableTexts.length}) ===`));
readableTexts.forEach((text, index) => {
console.log(`${chalk_1.default.yellow(`[${index + 1}]`)} ${text}`);
});
}
}
}
db.close();
});
});
// Export attachments command
program
.command('export-attachments')
.description('Export attachments from a chat')
.argument('<chat>', 'chat name or ID')
.option('-o, --output <dir>', 'output directory', './attachments')
.option('--after <date>', 'only attachments after this date (YYYY-MM-DD)')
.action(async (chatIdentifier, options) => {
console.log(chalk_1.default.yellow('Attachment export is not yet implemented'));
// TODO: Implement attachment export
});
// Stats command
program
.command('stats')
.description('Show statistics about messages')
.option('-c, --chat <name>', 'specific chat name')
.action(async (options) => {
const db = new imessage_database_1.IMessageDatabase();
try {
const chats = await db.getChats();
if (options.chat) {
// Stats for specific chat
const chat = chats.find(c => c.display_name === options.chat ||
c.chat_identifier.includes(options.chat));
if (!chat) {
console.error(chalk_1.default.red(`Chat "${options.chat}" not found`));
return;
}
const messages = await db.getMessagesFromChat(chat.ROWID, 10000);
showChatStats(chat, messages);
}
else {
// Overall stats
console.log(chalk_1.default.blue('\n=== iMessage Database Statistics ===\n'));
console.log(`${chalk_1.default.yellow('Total chats:')} ${chats.length}`);
// Get message counts for top chats
const chatStats = [];
for (const chat of chats.slice(0, 10)) {
const messages = await db.getMessagesFromChat(chat.ROWID, 1);
const totalQuery = `
SELECT COUNT(*) as count
FROM chat_message_join
WHERE chat_id = ?
`;
// This is a simplified version - in production you'd query the count
chatStats.push({
name: chat.display_name || chat.chat_identifier,
messageCount: messages.length,
});
}
console.log(chalk_1.default.blue('\n=== Top Chats ==='));
chatStats.forEach((stat, index) => {
console.log(`${index + 1}. ${stat.name}`);
});
}
}
catch (error) {
console.error(chalk_1.default.red('Error:'), error);
}
finally {
await db.close();
}
});
// Helper functions
function formatAsText(messages, includeFormatting) {
let output = '';
messages.reverse().forEach(msg => {
const date = new Date((msg.date / 1000000000) + 978307200000);
const sender = msg.is_from_me ? 'Me' : msg.handle_id || 'Unknown';
output += `[${date.toLocaleString()}] ${sender}: ${msg.parsed.text}\n`;
if (includeFormatting && msg.parsed.attributes && Object.keys(msg.parsed.attributes).length > 0) {
output += ` Formatting: ${JSON.stringify(msg.parsed.attributes)}\n`;
}
output += '\n';
});
return output;
}
function formatAsJSON(messages, includeFormatting) {
const data = messages.map(msg => ({
date: new Date((msg.date / 1000000000) + 978307200000).toISOString(),
sender: msg.is_from_me ? 'Me' : msg.handle_id || 'Unknown',
text: msg.parsed.text,
...(includeFormatting && msg.parsed.attributes ? { formatting: msg.parsed.attributes } : {}),
}));
return JSON.stringify(data, null, 2);
}
function formatAsMarkdown(messages, chatName, includeFormatting) {
let output = `# ${chatName}\n\n`;
output += `**Messages:** ${messages.length} \n`;
output += `**Date Range:** ${getDateRange(messages)} \n\n`;
output += '---\n\n';
let lastDate = '';
messages.reverse().forEach(msg => {
const date = new Date((msg.date / 1000000000) + 978307200000);
const dateStr = date.toLocaleDateString();
const timeStr = date.toLocaleTimeString();
const sender = msg.is_from_me ? 'Me' : msg.handle_id || 'Unknown';
if (dateStr !== lastDate) {
output += `\n## ${dateStr}\n\n`;
lastDate = dateStr;
}
if (msg.parsed.link) {
output += `**[${timeStr}] [${sender}](${msg.parsed.link}):** \n`;
}
else {
output += `**[${timeStr}] ${sender}:** \n`;
}
// Apply formatting if available
let text = msg.parsed.text;
if (includeFormatting && msg.parsed.attributes) {
text = applyMarkdownFormatting(text, msg.parsed.attributes);
}
output += `${text}\n\n`;
});
return output;
}
function getDateRange(messages) {
if (messages.length === 0)
return 'N/A';
const dates = messages.map(msg => (msg.date / 1000000000) + 978307200000);
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
return `${minDate.toLocaleDateString()} - ${maxDate.toLocaleDateString()}`;
}
function applyMarkdownFormatting(text, attributes) {
// This is a simplified version - in a real implementation,
// you'd need to handle overlapping ranges properly
if (attributes.links) {
attributes.links.forEach((link) => {
const linkText = text.substring(link.location, link.location + link.length);
text = text.replace(linkText, `[${linkText}](${link.url})`);
});
}
if (attributes.bold) {
attributes.bold.forEach((range) => {
const boldText = text.substring(range.location, range.location + range.length);
text = text.replace(boldText, `**${boldText}**`);
});
}
if (attributes.italic) {
attributes.italic.forEach((range) => {
const italicText = text.substring(range.location, range.location + range.length);
text = text.replace(italicText, `*${italicText}*`);
});
}
return text;
}
function showChatStats(chat, messages) {
console.log(chalk_1.default.blue(`\n=== Stats for ${chat.display_name || chat.chat_identifier} ===\n`));
const sentCount = messages.filter(m => m.is_from_me).length;
const receivedCount = messages.length - sentCount;
const withAttachments = messages.filter(m => m.cache_has_attachments).length;
const withAttributedBody = messages.filter(m => m.attributedBody).length;
console.log(`${chalk_1.default.yellow('Total messages:')} ${messages.length}`);
console.log(`${chalk_1.default.yellow('Sent by me:')} ${sentCount}`);
console.log(`${chalk_1.default.yellow('Received:')} ${receivedCount}`);
console.log(`${chalk_1.default.yellow('With attachments:')} ${withAttachments}`);
console.log(`${chalk_1.default.yellow('With formatting:')} ${withAttributedBody}`);
if (messages.length > 0) {
const dates = messages.map(msg => (msg.date / 1000000000) + 978307200000);
const firstDate = new Date(Math.min(...dates));
const lastDate = new Date(Math.max(...dates));
console.log(`${chalk_1.default.yellow('First message:')} ${firstDate.toLocaleString()}`);
console.log(`${chalk_1.default.yellow('Last message:')} ${lastDate.toLocaleString()}`);
}
}
// Run the CLI
program.parse();
//# sourceMappingURL=cli.js.map