diff --git a/src/account_facebook/facebook_account_controller.ts b/src/account_facebook/facebook_account_controller.ts new file mode 100644 index 00000000..1233bb21 --- /dev/null +++ b/src/account_facebook/facebook_account_controller.ts @@ -0,0 +1,237 @@ +import path from 'path' + +import fetch from 'node-fetch'; +import { session } from 'electron' +import log from 'electron-log/main'; +import Database from 'better-sqlite3' + +import { + getAccountDataPath, +} from '../util' +import { + FacebookAccount, + FacebookJob, + FacebookProgress, + emptyFacebookProgress, +} from '../shared_types' +import { + runMigrations, + getAccount, + exec, + getConfig, + setConfig, +} from '../database' +import { IMITMController } from '../mitm'; +import { + FacebookJobRow, + convertFacebookJobRowToFacebookJob, +} from './types' + +export class FacebookAccountController { + private accountUUID: string = ""; + // Making this public so it can be accessed in tests + public account: FacebookAccount | null = null; + private accountID: number = 0; + private accountDataPath: string = ""; + + // Making this public so it can be accessed in tests + public db: Database.Database | null = null; + + public mitmController: IMITMController; + private progress: FacebookProgress = emptyFacebookProgress(); + + private cookies: Record = {}; + + constructor(accountID: number, mitmController: IMITMController) { + this.mitmController = mitmController; + + this.accountID = accountID; + this.refreshAccount(); + + // Monitor web request metadata + const ses = session.fromPartition(`persist:account-${this.accountID}`); + ses.webRequest.onCompleted((_details) => { + // TODO: Monitor for rate limits + }); + + ses.webRequest.onSendHeaders((details) => { + // Keep track of cookies + if (details.url.startsWith("https://www.facebook.com/") && details.requestHeaders) { + this.cookies = {}; + + const cookieHeader = details.requestHeaders['Cookie']; + if (cookieHeader) { + const cookies = cookieHeader.split(';'); + cookies.forEach((cookie) => { + const parts = cookie.split('='); + if (parts.length == 2) { + this.cookies[parts[0].trim()] = parts[1].trim(); + } + }); + } + } + }); + } + + cleanup() { + if (this.db) { + this.db.close(); + this.db = null; + } + } + + refreshAccount() { + // Load the account + const account = getAccount(this.accountID); + if (!account) { + log.error(`FacebookAccountController.refreshAccount: account ${this.accountID} not found`); + return; + } + + // Make sure it's a Facebook account + if (account.type != "Facebook") { + log.error(`FacebookAccountController.refreshAccount: account ${this.accountID} is not a Facebook account`); + return; + } + + // Get the account UUID + this.accountUUID = account.uuid; + log.debug(`FacebookAccountController.refreshAccount: accountUUID=${this.accountUUID}`); + + // Load the Facebook account + this.account = account.facebookAccount; + if (!this.account) { + log.error(`FacebookAccountController.refreshAccount: xAccount ${this.accountID} not found`); + return; + } + } + + initDB() { + if (!this.account || !this.account.accountID) { + log.error("FacebookAccountController: cannot initialize the database because the account is not found, or the account Facebook ID is not found", this.account, this.account?.accountID); + return; + } + + // Make sure the account data folder exists + this.accountDataPath = getAccountDataPath('X', this.account.name); + log.info(`FacebookAccountController.initDB: accountDataPath=${this.accountDataPath}`); + + // Open the database + this.db = new Database(path.join(this.accountDataPath, 'data.sqlite3'), {}); + this.db.pragma('journal_mode = WAL'); + runMigrations(this.db, [ + // Create the tables + { + name: "initial", + sql: [ + `CREATE TABLE job ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jobType TEXT NOT NULL, + status TEXT NOT NULL, + scheduledAt DATETIME NOT NULL, + startedAt DATETIME, + finishedAt DATETIME, + progressJSON TEXT, + error TEXT +);`, + `CREATE TABLE config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL +);` + ] + }, + ]) + log.info("FacebookAccountController.initDB: database initialized"); + } + + resetProgress(): FacebookProgress { + log.debug("FacebookAccountController.resetProgress"); + this.progress = emptyFacebookProgress(); + return this.progress; + } + + createJobs(jobTypes: string[]): FacebookJob[] { + if (!this.db) { + this.initDB(); + } + + // Cancel pending jobs + exec(this.db, "UPDATE job SET status = ? WHERE status = ?", ["canceled", "pending"]); + + // Create new pending jobs + jobTypes.forEach((jobType) => { + exec(this.db, 'INSERT INTO job (jobType, status, scheduledAt) VALUES (?, ?, ?)', [ + jobType, + 'pending', + new Date(), + ]); + }); + + // Select pending jobs + const jobs: FacebookJobRow[] = exec(this.db, "SELECT * FROM job WHERE status = ? ORDER BY id", ["pending"], "all") as FacebookJobRow[]; + return jobs.map(convertFacebookJobRowToFacebookJob); + } + + updateJob(job: FacebookJob) { + if (!this.db) { + this.initDB(); + } + + exec( + this.db, + 'UPDATE job SET status = ?, startedAt = ?, finishedAt = ?, progressJSON = ?, error = ? WHERE id = ?', + [job.status, job.startedAt ? job.startedAt : null, job.finishedAt ? job.finishedAt : null, job.progressJSON, job.error, job.id] + ); + } + + async archiveBuild() { + if (!this.db) { + this.initDB(); + } + + if (!this.account) { + return false; + } + + log.info("FacebookAccountController.archiveBuild: building archive"); + + // TODO: implement + } + + async syncProgress(progressJSON: string) { + this.progress = JSON.parse(progressJSON); + } + + async getProgress(): Promise { + return this.progress; + } + + async getCookie(name: string): Promise { + return this.cookies[name] || null; + } + + async getProfileImageDataURI(profilePictureURI: string): Promise { + log.info("FacebookAccountController.getProfileImageDataURI: profilePictureURI", profilePictureURI); + try { + const response = await fetch(profilePictureURI, {}); + if (!response.ok) { + return ""; + } + const buffer = await response.buffer(); + log.info("FacebookAccountController.getProfileImageDataURI: buffer", buffer); + return `data:${response.headers.get('content-type')};base64,${buffer.toString('base64')}`; + } catch (e) { + log.error("FacebookAccountController.getProfileImageDataURI: error", e); + return ""; + } + } + + async getConfig(key: string): Promise { + return getConfig(key, this.db); + } + + async setConfig(key: string, value: string) { + return setConfig(key, value, this.db); + } +} diff --git a/src/account_facebook/index.ts b/src/account_facebook/index.ts new file mode 100644 index 00000000..6414e0ee --- /dev/null +++ b/src/account_facebook/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './facebook_account_controller'; +export * from './ipc'; diff --git a/src/account_facebook/ipc.ts b/src/account_facebook/ipc.ts new file mode 100644 index 00000000..3095ac74 --- /dev/null +++ b/src/account_facebook/ipc.ts @@ -0,0 +1,113 @@ +import { ipcMain } from 'electron' + +import { FacebookAccountController } from './facebook_account_controller'; + +import { + FacebookJob, + FacebookProgress, +} from '../shared_types' +import { getMITMController } from '../mitm'; +import { packageExceptionForReport } from '../util' + +const controllers: Record = {}; + +const getFacebookAccountController = (accountID: number): FacebookAccountController => { + if (!controllers[accountID]) { + controllers[accountID] = new FacebookAccountController(accountID, getMITMController(accountID)); + } + controllers[accountID].refreshAccount(); + return controllers[accountID]; +} + +export const defineIPCFacebook = () => { + ipcMain.handle('Facebook:resetProgress', async (_, accountID: number): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return controller.resetProgress(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:createJobs', async (_, accountID: number, jobTypes: string[]): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return controller.createJobs(jobTypes); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:updateJob', async (_, accountID: number, jobJSON: string) => { + try { + const controller = getFacebookAccountController(accountID); + const job = JSON.parse(jobJSON) as FacebookJob; + controller.updateJob(job); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:archiveBuild', async (_, accountID: number): Promise => { + try { + const controller = getFacebookAccountController(accountID); + await controller.archiveBuild(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:syncProgress', async (_, accountID: number, progressJSON: string) => { + try { + const controller = getFacebookAccountController(accountID); + await controller.syncProgress(progressJSON); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:getProgress', async (_, accountID: number): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return await controller.getProgress(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:getCookie', async (_, accountID: number, name: string): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return await controller.getCookie(name); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:getProfileImageDataURI', async (_, accountID: number, profilePictureURI: string): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return await controller.getProfileImageDataURI(profilePictureURI); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:getConfig', async (_, accountID: number, key: string): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return await controller.getConfig(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('Facebook:setConfig', async (_, accountID: number, key: string, value: string): Promise => { + try { + const controller = getFacebookAccountController(accountID); + return await controller.setConfig(key, value); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); +}; diff --git a/src/account_facebook/types.ts b/src/account_facebook/types.ts new file mode 100644 index 00000000..6fba03b9 --- /dev/null +++ b/src/account_facebook/types.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { FacebookJob } from '../shared_types' + +// Models + +export interface FacebookJobRow { + id: number; + jobType: string; + status: string; + scheduledAt: string; + startedAt: string | null; + finishedAt: string | null; + progressJSON: string | null; + error: string | null; +} + +// Converters + +export function convertFacebookJobRowToFacebookJob(row: FacebookJobRow): FacebookJob { + return { + id: row.id, + jobType: row.jobType, + status: row.status, + scheduledAt: new Date(row.scheduledAt), + startedAt: row.startedAt ? new Date(row.startedAt) : null, + finishedAt: row.finishedAt ? new Date(row.finishedAt) : null, + progressJSON: row.progressJSON ? JSON.parse(row.progressJSON) : null, + error: row.error, + }; +} diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts index 3a0f7260..22a33727 100644 --- a/src/account_x/ipc.ts +++ b/src/account_x/ipc.ts @@ -3,6 +3,7 @@ import { ipcMain } from 'electron' import { XAccountController } from './x_account_controller'; import { + ArchiveInfo, XAccount, XJob, XProgress, @@ -14,7 +15,6 @@ import { ResponseData, XDatabaseStats, XDeleteReviewStats, - XArchiveInfo, XImportArchiveResponse } from '../shared_types' import { getMITMController } from '../mitm'; @@ -221,7 +221,7 @@ export const defineIPCX = () => { } }); - ipcMain.handle('X:getArchiveInfo', async (_, accountID: number): Promise => { + ipcMain.handle('X:getArchiveInfo', async (_, accountID: number): Promise => { try { const controller = getXAccountController(accountID); return await controller.getArchiveInfo(); diff --git a/src/account_x/x_account_controller.ts b/src/account_x/x_account_controller.ts index 420ab736..58b47fcf 100644 --- a/src/account_x/x_account_controller.ts +++ b/src/account_x/x_account_controller.ts @@ -5,7 +5,7 @@ import os from 'os' import fetch from 'node-fetch'; import unzipper from 'unzipper'; -import { app, session, shell } from 'electron' +import { app, session } from 'electron' import log from 'electron-log/main'; import Database from 'better-sqlite3' import { glob } from 'glob'; @@ -28,7 +28,6 @@ import { ResponseData, XDatabaseStats, emptyXDatabaseStats, XDeleteReviewStats, emptyXDeleteReviewStats, - XArchiveInfo, emptyXArchiveInfo, XImportArchiveResponse } from '../shared_types' import { @@ -141,7 +140,6 @@ export class XAccountController { this.cookies[parts[0].trim()] = parts[1].trim(); } }); - // log.info("XAccountController: cookies", this.cookies); } } }); @@ -1520,27 +1518,6 @@ export class XAccountController { this.progress = JSON.parse(progressJSON); } - async openFolder(folderName: string) { - if (!this.account) { - return; - } - const folderPath = path.join(getAccountDataPath("X", this.account?.username), folderName); - await shell.openPath(folderPath); - } - - async getArchiveInfo(): Promise { - const archiveInfo = emptyXArchiveInfo(); - if (!this.account || !this.account.username) { - return archiveInfo; - } - const accountDataPath = getAccountDataPath("X", this.account?.username); - const indexHTMLFilename = path.join(accountDataPath, "index.html"); - - archiveInfo.folderEmpty = !fs.existsSync(accountDataPath) || fs.readdirSync(accountDataPath).length === 0; - archiveInfo.indexHTMLExists = fs.existsSync(indexHTMLFilename); - return archiveInfo; - } - async resetRateLimitInfo(): Promise { this.rateLimitInfo = emptyXRateLimitInfo(); } diff --git a/src/archive.ts b/src/archive.ts index 644fc9c3..7d75ca96 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -1,13 +1,37 @@ import fs from 'fs'; import path from 'path'; -import { ipcMain, webContents } from 'electron'; +import { ipcMain, webContents, shell } from 'electron'; +import log from 'electron-log/main'; -import { packageExceptionForReport } from './util'; +import { packageExceptionForReport, getAccountDataPath, getDataPath } from './util'; +import { Account, ArchiveInfo, emptyArchiveInfo } from './shared_types'; +import { getAccount } from './database' import mhtml2html from 'mhtml2html'; import { JSDOM } from 'jsdom'; +const getArchivePath = (account: Account): string | null => { + if (account.type == "X" && account.xAccount && account.xAccount.username) { + return getAccountDataPath("X", account.xAccount.username); + } else if (account.type == "Facebook" && account.facebookAccount && account.facebookAccount.accountID && account.facebookAccount.name) { + const facebookDataPath = path.join(getDataPath(), "Facebook"); + + // See if there is a folder in this path that starts with account.facebookAccount.accountID + // This way if the user changes their name, we can still find the folder + const facebookAccountFolders = fs.readdirSync(facebookDataPath); + for (const folder of facebookAccountFolders) { + if (folder.startsWith(account.facebookAccount.accountID)) { + return path.join(facebookDataPath, folder); + } + } + + // Otherwise, fallback to the accountID and name + return getAccountDataPath("Facebook", `${account.facebookAccount.accountID} ${account.facebookAccount.name}`); + } + return null; +} + export const defineIPCArchive = () => { ipcMain.handle('archive:isPageAlreadySaved', async (_, outputPath: string, basename: string): Promise => { try { @@ -48,4 +72,43 @@ export const defineIPCArchive = () => { throw new Error(packageExceptionForReport(error as Error)); } }); + + ipcMain.handle('archive:openFolder', async (_, accountID: number, folderName: string): Promise => { + const account = getAccount(accountID); + if (!account) { + log.error('archive:openFolder', `Account not found: ${accountID}`); + return; + } + + const archivePath = getArchivePath(account); + if (!archivePath) { + log.error('archive:openFolder', `error getting archive path`); + return; + } + + const folderPath = path.join(archivePath, folderName); + await shell.openPath(folderPath); + }); + + ipcMain.handle('archive:getInfo', async (_, accountID: number): Promise => { + const archiveInfo = emptyArchiveInfo(); + + const account = getAccount(accountID); + if (!account) { + log.error('archive:getInfo', `Account not found: ${accountID}`); + return archiveInfo; + } + + const archivePath = getArchivePath(account); + if (!archivePath) { + log.error('archive:getInfo', `error getting archive path`); + return archiveInfo; + } + + const indexHTMLFilename = path.join(archivePath, "index.html"); + + archiveInfo.folderEmpty = !fs.existsSync(archivePath) || fs.readdirSync(archivePath).length === 0; + archiveInfo.indexHTMLExists = fs.existsSync(indexHTMLFilename); + return archiveInfo; + }); } diff --git a/src/database/account.ts b/src/database/account.ts index d5eb5b20..c986629b 100644 --- a/src/database/account.ts +++ b/src/database/account.ts @@ -3,7 +3,8 @@ import { ipcMain, session } from 'electron'; import { exec, getMainDatabase, Sqlite3Info } from './common'; import { createXAccount, getXAccount, saveXAccount } from './x_account'; import { createBlueskyAccount, getBlueskyAccount, saveBlueskyAccount } from './bluesky_account'; -import { Account, XAccount, BlueskyAccount } from '../shared_types' +import { createFacebookAccount, getFacebookAccount, saveFacebookAccount } from './facebook_account'; +import { Account, XAccount, BlueskyAccount, FacebookAccount } from '../shared_types' import { packageExceptionForReport } from "../util" // Types @@ -14,19 +15,14 @@ interface AccountRow { sortOrder: number; xAccountId: number | null; blueskyAccountID: number | null; + facebookAccountID: number | null; uuid: string; } -// Functions - -export const getAccount = (id: number): Account | null => { - const row: AccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM account WHERE id = ?', [id], 'get') as AccountRow | undefined; - if (!row) { - return null; - } - +function accountFromAccountRow(row: AccountRow): Account { let xAccount: XAccount | null = null; let blueskyAccount: BlueskyAccount | null = null; + let facebookAccount: FacebookAccount | null = null; switch (row.type) { case "X": if (row.xAccountId) { @@ -39,6 +35,12 @@ export const getAccount = (id: number): Account | null => { blueskyAccount = getBlueskyAccount(row.blueskyAccountID); } break; + + case "Facebook": + if (row.facebookAccountID) { + facebookAccount = getFacebookAccount(row.facebookAccountID); + } + break; } return { @@ -47,15 +49,29 @@ export const getAccount = (id: number): Account | null => { sortOrder: row.sortOrder, xAccount: xAccount, blueskyAccount: blueskyAccount, + facebookAccount: facebookAccount, uuid: row.uuid }; } +// Functions + +export const getAccount = (id: number): Account | null => { + const row: AccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM account WHERE id = ?', [id], 'get') as AccountRow | undefined; + if (!row) { + return null; + } + + return accountFromAccountRow(row); +} + export async function getAccountUsername(account: Account): Promise { if (account.type == "X" && account.xAccount) { return account.xAccount?.username; } else if (account.type == "Bluesky" && account.blueskyAccount) { return account.blueskyAccount?.username; + } else if (account.type == "Facebook" && account.facebookAccount) { + return account.facebookAccount?.name; } return null; @@ -66,29 +82,7 @@ export const getAccounts = (): Account[] => { const accounts: Account[] = []; for (const row of rows) { - let xAccount: XAccount | null = null; - let blueskyAccount: BlueskyAccount | null = null; - switch (row.type) { - case "X": - if (row.xAccountId) { - xAccount = getXAccount(row.xAccountId); - } - break; - case "Bluesky": - if (row.blueskyAccountID) { - blueskyAccount = getBlueskyAccount(row.blueskyAccountID); - } - break - } - - accounts.push({ - id: row.id, - type: row.type, - sortOrder: row.sortOrder, - xAccount: xAccount, - blueskyAccount: blueskyAccount, - uuid: row.uuid - }); + accounts.push(accountFromAccountRow(row)); } return accounts; } @@ -129,12 +123,16 @@ export const selectAccountType = (accountID: number, type: string): Account => { case "Bluesky": account.blueskyAccount = createBlueskyAccount(); break; + case "Facebook": + account.facebookAccount = createFacebookAccount(); + break; default: throw new Error("Unknown account type"); } const xAccountId = account.xAccount ? account.xAccount.id : null; const blueskyAccountID = account.blueskyAccount ? account.blueskyAccount.id : null; + const facebookAccountID = account.facebookAccount ? account.facebookAccount.id : null; // Update the account exec(getMainDatabase(), ` @@ -142,12 +140,14 @@ export const selectAccountType = (accountID: number, type: string): Account => { SET type = ?, xAccountId = ?, - blueskyAccountID = ? + blueskyAccountID = ?, + facebookAccountID = ? WHERE id = ? `, [ type, xAccountId, blueskyAccountID, + facebookAccountID, account.id ]); @@ -159,9 +159,10 @@ export const selectAccountType = (accountID: number, type: string): Account => { export const saveAccount = (account: Account) => { if (account.xAccount) { saveXAccount(account.xAccount); - } - else if (account.blueskyAccount) { + } else if (account.blueskyAccount) { saveBlueskyAccount(account.blueskyAccount); + } else if (account.facebookAccount) { + saveFacebookAccount(account.facebookAccount); } exec(getMainDatabase(), ` @@ -196,6 +197,11 @@ export const deleteAccount = (accountID: number) => { exec(getMainDatabase(), 'DELETE FROM blueskyAccount WHERE id = ?', [account.blueskyAccount.id]); } break; + case "Facebook": + if (account.facebookAccount) { + exec(getMainDatabase(), 'DELETE FROM facebookAccount WHERE id = ?', [account.facebookAccount.id]); + } + break; } // Delete the account diff --git a/src/database/facebook_account.ts b/src/database/facebook_account.ts new file mode 100644 index 00000000..39d07975 --- /dev/null +++ b/src/database/facebook_account.ts @@ -0,0 +1,113 @@ +import { exec, getMainDatabase, Sqlite3Info } from './common'; +import { FacebookAccount } from '../shared_types' + +// Types + +export interface FacebookAccountRow { + id: number; + createdAt: string; + updatedAt: string; + accessedAt: string; + accountID: string; + name: string; + profileImageDataURI: string; + saveMyData: boolean; + deleteMyData: boolean; + savePosts: boolean; + savePostsHTML: boolean; + deletePosts: boolean; + deletePostsDaysOldEnabled: boolean; + deletePostsDaysOld: number; + deletePostsReactsThresholdEnabled: boolean; + deletePostsReactsThreshold: number; +} + +function facebookAccountRowToFacebookAccount(row: FacebookAccountRow): FacebookAccount { + return { + id: row.id, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + accessedAt: new Date(row.accessedAt), + accountID: row.accountID, + name: row.name, + profileImageDataURI: row.profileImageDataURI, + saveMyData: !!row.saveMyData, + deleteMyData: !!row.deleteMyData, + savePosts: !!row.savePosts, + savePostsHTML: !!row.savePostsHTML, + deletePosts: !!row.deletePosts, + deletePostsDaysOldEnabled: !!row.deletePostsDaysOldEnabled, + deletePostsDaysOld: row.deletePostsDaysOld, + deletePostsReactsThresholdEnabled: !!row.deletePostsReactsThresholdEnabled, + deletePostsReactsThreshold: row.deletePostsReactsThreshold + }; +} + +// Functions + +// Get a single Facebook account by ID +export const getFacebookAccount = (id: number): FacebookAccount | null => { + const row: FacebookAccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM facebookAccount WHERE id = ?', [id], 'get') as FacebookAccountRow | undefined; + if (!row) { + return null; + } + return facebookAccountRowToFacebookAccount(row); +} + +// Get all Facebook accounts +export const getFacebookAccounts = (): FacebookAccount[] => { + const rows: FacebookAccountRow[] = exec(getMainDatabase(), 'SELECT * FROM facebookAccount', [], 'all') as FacebookAccountRow[]; + + const accounts: FacebookAccount[] = []; + for (const row of rows) { + accounts.push(facebookAccountRowToFacebookAccount(row)); + } + return accounts; +} + +// Create a new Facebook account +export const createFacebookAccount = (): FacebookAccount => { + const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO facebookAccount DEFAULT VALUES') as Sqlite3Info; + const account = getFacebookAccount(info.lastInsertRowid); + if (!account) { + throw new Error("Failed to create account"); + } + return account; +} + +// Update the Facebook account based on account.id +export const saveFacebookAccount = (account: FacebookAccount) => { + exec(getMainDatabase(), ` + UPDATE facebookAccount + SET + updatedAt = CURRENT_TIMESTAMP, + accessedAt = CURRENT_TIMESTAMP, + accountID = ?, + name = ?, + profileImageDataURI = ?, + saveMyData = ?, + deleteMyData = ?, + savePosts = ?, + savePostsHTML = ?, + deletePosts = ?, + deletePostsDaysOldEnabled = ?, + deletePostsDaysOld = ?, + deletePostsReactsThresholdEnabled = ?, + deletePostsReactsThreshold = ? + WHERE id = ? + `, [ + account.accountID, + account.name, + account.profileImageDataURI, + account.saveMyData ? 1 : 0, + account.deleteMyData ? 1 : 0, + account.savePosts ? 1 : 0, + account.savePostsHTML ? 1 : 0, + account.deletePosts ? 1 : 0, + account.deletePostsDaysOldEnabled ? 1 : 0, + account.deletePostsDaysOld, + account.deletePostsReactsThresholdEnabled ? 1 : 0, + account.deletePostsReactsThreshold, + account.id + ]); +} diff --git a/src/database/migrations.ts b/src/database/migrations.ts index 3abe8afa..19dc92ba 100644 --- a/src/database/migrations.ts +++ b/src/database/migrations.ts @@ -143,5 +143,30 @@ export const runMainMigrations = () => { `ALTER TABLE account ADD COLUMN blueskyAccountID INTEGER DEFAULT NULL;`, ] }, + // Add Facebook table, and facebookAccountID to account + { + name: "add Facebook table, and facebookAccountID to account", + sql: [ + `CREATE TABLE facebookAccount ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + accessedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + accountID TEXT, + name TEXT, + profileImageDataURI TEXT, + saveMyData BOOLEAN DEFAULT 1, + deleteMyData BOOLEAN DEFAULT 0, + savePosts BOOLEAN DEFAULT 1, + savePostsHTML BOOLEAN DEFAULT 0, + deletePosts BOOLEAN DEFAULT 1, + deletePostsDaysOldEnabled BOOLEAN DEFAULT 0, + deletePostsDaysOld INTEGER DEFAULT 0, + deletePostsReactsThresholdEnabled BOOLEAN DEFAULT 0, + deletePostsReactsThreshold INTEGER DEFAULT 20 +);`, + `ALTER TABLE account ADD COLUMN facebookAccountID INTEGER DEFAULT NULL;`, + ] + }, ]); } \ No newline at end of file diff --git a/src/database/x_account.ts b/src/database/x_account.ts index 4885bd87..94dc8f43 100644 --- a/src/database/x_account.ts +++ b/src/database/x_account.ts @@ -39,13 +39,7 @@ interface XAccountRow { likesCount: number; } -// Functions - -export const getXAccount = (id: number): XAccount | null => { - const row: XAccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM xAccount WHERE id = ?', [id], 'get') as XAccountRow | undefined; - if (!row) { - return null; - } +function xAccountRowtoXAccount(row: XAccountRow): XAccount { return { id: row.id, createdAt: new Date(row.createdAt), @@ -80,7 +74,17 @@ export const getXAccount = (id: number): XAccount | null => { followersCount: row.followersCount, tweetsCount: row.tweetsCount, likesCount: row.likesCount - }; + } +} + +// Functions + +export const getXAccount = (id: number): XAccount | null => { + const row: XAccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM xAccount WHERE id = ?', [id], 'get') as XAccountRow | undefined; + if (!row) { + return null; + } + return xAccountRowtoXAccount(row); } export const getXAccounts = (): XAccount[] => { @@ -88,41 +92,7 @@ export const getXAccounts = (): XAccount[] => { const accounts: XAccount[] = []; for (const row of rows) { - accounts.push({ - id: row.id, - createdAt: new Date(row.createdAt), - updatedAt: new Date(row.updatedAt), - accessedAt: new Date(row.accessedAt), - username: row.username, - profileImageDataURI: row.profileImageDataURI, - importFromArchive: !!row.importFromArchive, - saveMyData: !!row.saveMyData, - deleteMyData: !!row.deleteMyData, - archiveMyData: !!row.archiveMyData, - archiveTweets: !!row.archiveTweets, - archiveTweetsHTML: !!row.archiveTweetsHTML, - archiveLikes: !!row.archiveLikes, - archiveBookmarks: !!row.archiveBookmarks, - archiveDMs: !!row.archiveDMs, - deleteTweets: !!row.deleteTweets, - deleteTweetsDaysOldEnabled: !!row.deleteTweetsDaysOldEnabled, - deleteTweetsDaysOld: row.deleteTweetsDaysOld, - deleteTweetsLikesThresholdEnabled: !!row.deleteTweetsLikesThresholdEnabled, - deleteTweetsLikesThreshold: row.deleteTweetsLikesThreshold, - deleteTweetsRetweetsThresholdEnabled: !!row.deleteTweetsRetweetsThresholdEnabled, - deleteTweetsRetweetsThreshold: row.deleteTweetsRetweetsThreshold, - deleteRetweets: !!row.deleteRetweets, - deleteRetweetsDaysOldEnabled: !!row.deleteRetweetsDaysOldEnabled, - deleteRetweetsDaysOld: row.deleteRetweetsDaysOld, - deleteLikes: !!row.deleteLikes, - deleteBookmarks: !!row.deleteBookmarks, - deleteDMs: !!row.deleteDMs, - unfollowEveryone: !!row.unfollowEveryone, - followingCount: row.followingCount, - followersCount: row.followersCount, - tweetsCount: row.tweetsCount, - likesCount: row.likesCount - }); + accounts.push(xAccountRowtoXAccount(row)); } return accounts; } diff --git a/src/main.ts b/src/main.ts index d4d4148f..99ed69c8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { updateElectronApp, UpdateSourceType } from 'update-electron-app'; import * as database from './database'; import { defineIPCX } from './account_x'; +import { defineIPCFacebook } from './account_facebook'; import { defineIPCArchive } from './archive'; import { getUpdatesBaseURL, @@ -29,7 +30,8 @@ import { getSettingsPath, getDataPath, trackEvent, - packageExceptionForReport + packageExceptionForReport, + isFeatureEnabled } from './util'; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; @@ -134,6 +136,7 @@ app.on('open-url', (event, url) => { openCydURL(url); }) +// Check if we're in dev mode const cydDevMode = process.env.CYD_DEV === "1"; async function initializeApp() { @@ -373,6 +376,10 @@ async function createWindow() { } }); + ipcMain.handle('isFeatureEnabled', async (_, feature: string): Promise => { + return isFeatureEnabled(feature); + }); + ipcMain.handle('trackEvent', async (_, eventName: string, userAgent: string) => { try { trackEvent(eventName, userAgent, config.plausibleDomain); @@ -541,6 +548,7 @@ async function createWindow() { // Other IPC events database.defineIPCDatabase(); defineIPCX(); + defineIPCFacebook(); defineIPCArchive(); } // @ts-expect-error: typescript doesn't know about this global variable diff --git a/src/preload.ts b/src/preload.ts index 4bd0c308..19c3b293 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -2,8 +2,11 @@ import { contextBridge, ipcRenderer, FileFilter } from 'electron' import { ErrorReport, Account, - XProgress, + ResponseData, + ArchiveInfo, + // X XJob, + XProgress, XArchiveStartResponse, XIndexMessagesStartResponse, XDeleteTweetsStartResponse, @@ -11,10 +14,11 @@ import { XProgressInfo, XDatabaseStats, XDeleteReviewStats, - ResponseData, - XArchiveInfo, XAccount, XImportArchiveResponse, + // Facebook + FacebookJob, + FacebookProgress, } from './shared_types' contextBridge.exposeInMainWorld('electron', { @@ -36,6 +40,9 @@ contextBridge.exposeInMainWorld('electron', { getDashURL: (): Promise => { return ipcRenderer.invoke('getDashURL') }, + isFeatureEnabled: (feature: string): Promise => { + return ipcRenderer.invoke('isFeatureEnabled', feature) + }, trackEvent: (eventName: string, userAgent: string): Promise => { return ipcRenderer.invoke('trackEvent', eventName, userAgent) }, @@ -119,7 +126,13 @@ contextBridge.exposeInMainWorld('electron', { }, savePage: (webContentsID: number, outputPath: string, basename: string): Promise => { return ipcRenderer.invoke('archive:savePage', webContentsID, outputPath, basename) - } + }, + openFolder: (accountID: number, folderName: string) => { + ipcRenderer.invoke('archive:openFolder', accountID, folderName); + }, + getInfo: (accountID: number): Promise => { + return ipcRenderer.invoke('archive:getInfo', accountID); + }, }, X: { resetProgress: (accountID: number): Promise => { @@ -188,12 +201,6 @@ contextBridge.exposeInMainWorld('electron', { syncProgress: (accountID: number, progressJSON: string) => { ipcRenderer.invoke('X:syncProgress', accountID, progressJSON) }, - openFolder: (accountID: number, folderName: string) => { - ipcRenderer.invoke('X:openFolder', accountID, folderName); - }, - getArchiveInfo: (accountID: number): Promise => { - return ipcRenderer.invoke('X:getArchiveInfo', accountID); - }, resetRateLimitInfo: (accountID: number): Promise => { return ipcRenderer.invoke('X:resetRateLimitInfo', accountID); }, @@ -264,6 +271,38 @@ contextBridge.exposeInMainWorld('electron', { return ipcRenderer.invoke('X:setConfig', accountID, key, value); } }, + Facebook: { + resetProgress: (accountID: number): Promise => { + return ipcRenderer.invoke('Facebook:resetProgress', accountID); + }, + createJobs: (accountID: number, jobTypes: string[]): Promise => { + return ipcRenderer.invoke('Facebook:createJobs', accountID, jobTypes); + }, + updateJob: (accountID: number, jobJSON: string) => { + ipcRenderer.invoke('Facebook:updateJob', accountID, jobJSON); + }, + archiveBuild: (accountID: number): Promise => { + return ipcRenderer.invoke('Facebook:archiveBuild', accountID); + }, + syncProgress: (accountID: number, progressJSON: string) => { + ipcRenderer.invoke('Facebook:syncProgress', accountID, progressJSON); + }, + getProgress: (accountID: number): Promise => { + return ipcRenderer.invoke('Facebook:getProgress', accountID); + }, + getCookie: (accountID: number, name: string): Promise => { + return ipcRenderer.invoke('Facebook:getCookie', accountID, name); + }, + getProfileImageDataURI: (accountID: number, profilePictureURI: string): Promise => { + return ipcRenderer.invoke('Facebook:getProfileImageDataURI', accountID, profilePictureURI); + }, + getConfig: (accountID: number, key: string): Promise => { + return ipcRenderer.invoke('Facebook:getConfig', accountID, key); + }, + setConfig: (accountID: number, key: string, value: string): Promise => { + return ipcRenderer.invoke('Facebook:setConfig', accountID, key, value); + }, + }, // Handle events from the main process onPowerMonitorSuspend: (callback: () => void) => { ipcRenderer.on('powerMonitor:suspend', callback); diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index a4860fbd..095a6327 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -424,6 +424,16 @@ body { border-radius: 0.25rem; } +.alpha { + text-transform: uppercase; + font-size: 0.7rem; + margin-left: 1rem; + padding: 0.2em 0.5em; + background-color: #e289ff; + color: white; + border-radius: 0.25rem; +} + .fa-heart { color: red; } diff --git a/src/renderer/src/automation_errors.ts b/src/renderer/src/automation_errors.ts index 46d996f0..2c361c7e 100644 --- a/src/renderer/src/automation_errors.ts +++ b/src/renderer/src/automation_errors.ts @@ -1,4 +1,5 @@ export enum AutomationErrorType { + // X X_manualBugReport = "X_manualBugReport", X_login_FailedToGetUsername = "X_login_FailedToGetUsername", X_login_URLChanged = "X_login_URLChanged", @@ -74,9 +75,15 @@ export enum AutomationErrorType { x_unknownError = "x_unknown", x_loadURLError = "x_loadURLError", x_loadURLURLChanged = "x_loadURLURLChanged", + + // Facebook + facebook_manualBugReport = "facebook_manualBugReport", + facebook_unknownError = "facebook_unknown", + facebook_loadURLError = "facebook_loadURLError", } export const AutomationErrorTypeToMessage = { + // X [AutomationErrorType.X_manualBugReport]: "You're manually reporting a bug", [AutomationErrorType.X_login_FailedToGetUsername]: "Failed to get username on login", [AutomationErrorType.X_login_URLChanged]: "URL changed on login", @@ -152,4 +159,9 @@ export const AutomationErrorTypeToMessage = { [AutomationErrorType.x_unknownError]: "An unknown error occurred", [AutomationErrorType.x_loadURLError]: "Error while loading URL", [AutomationErrorType.x_loadURLURLChanged]: "URL changed after loading", + + // Facebook + [AutomationErrorType.facebook_manualBugReport]: "You're manually reporting a bug", + [AutomationErrorType.facebook_unknownError]: "An unknown error occurred", + [AutomationErrorType.facebook_loadURLError]: "Error while loading URL", } diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 25c17d13..85c9cdc2 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -8,19 +8,23 @@ import { createApp } from "vue"; import type { ErrorReport, Account, - XProgress, + ResponseData, + ArchiveInfo, + // X XJob, + XProgress, XArchiveStartResponse, XIndexMessagesStartResponse, XDeleteTweetsStartResponse, XRateLimitInfo, XProgressInfo, XDatabaseStats, - ResponseData, XDeleteReviewStats, - XArchiveInfo, XAccount, XImportArchiveResponse, + // Facebook + FacebookProgress, + FacebookJob, } from "../../shared_types"; import App from "./App.vue"; @@ -29,50 +33,53 @@ import { FileFilter } from "electron"; declare global { interface Window { electron: { - checkForUpdates: () => void; + checkForUpdates: () => Promise; getVersion: () => Promise; getMode: () => Promise; getPlatform: () => Promise; getAPIURL: () => Promise; getDashURL: () => Promise; + isFeatureEnabled: (feature: string) => Promise; trackEvent: (eventName: string, userAgent: string) => Promise; shouldOpenDevtools: () => Promise; - showMessage: (message: string, detail: string) => void; - showError: (message: string) => void; + showMessage: (message: string, detail: string) => Promise; + showError: (message: string) => Promise; showQuestion: (message: string, trueText: string, falseText: string) => Promise; showOpenDialog: (selectFolders: boolean, selectFiles: boolean, fileFilters: FileFilter[] | undefined) => Promise; - openURL: (url: string) => void; - loadFileInWebview: (webContentsId: number, filename: string) => void; + openURL: (url: string) => Promise; + loadFileInWebview: (webContentsId: number, filename: string) => Promise; getAccountDataPath: (accountID: number, filename: string) => Promise, startPowerSaveBlocker: () => Promise; - stopPowerSaveBlocker: (powerSaveBlockerID: number) => void; - deleteSettingsAndRestart: () => void; + stopPowerSaveBlocker: (powerSaveBlockerID: number) => Promise; + deleteSettingsAndRestart: () => Promise; database: { getConfig: (key: string) => Promise; - setConfig: (key: string, value: string) => void; + setConfig: (key: string, value: string) => Promise; getErrorReport: (id: number) => Promise; getNewErrorReports: (accountID: number) => Promise; createErrorReport: (accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null) => Promise; - updateErrorReportSubmitted: (id: number) => void; - dismissNewErrorReports: (accountID: number) => void; + updateErrorReportSubmitted: (id: number) => Promise; + dismissNewErrorReports: (accountID: number) => Promise; getAccount: (accountID: number) => Promise; getAccounts: () => Promise; createAccount: () => Promise; selectAccountType: (accountID: number, type: string) => Promise; - saveAccount: (accountJSON: string) => void; - deleteAccount: (accountID: number) => void; + saveAccount: (accountJSON: string) => Promise; + deleteAccount: (accountID: number) => Promise; }, archive: { isPageAlreadySaved: (outputPath: string, basename: string) => Promise; savePage: (webContentsID: number, outputPath: string, basename: string) => Promise; + openFolder: (accountID: number, folderName: string) => Promise; + getInfo: (accountID: number) => Promise; }, X: { resetProgress: (accountID: number) => Promise; createJobs: (accountID: number, jobTypes: string[]) => Promise; getLastFinishedJob: (accountID: number, jobType: string) => Promise; - updateJob: (accountID: number, jobJSON: string) => void; - indexStart: (accountID: number) => void; - indexStop: (accountID: number) => void; + updateJob: (accountID: number, jobJSON: string) => Promise; + indexStart: (accountID: number) => Promise; + indexStop: (accountID: number) => Promise; indexParseAllJSON: (accountID: number) => Promise; indexParseTweets: (accountID: number) => Promise; indexParseConversations: (accountID: number) => Promise; @@ -86,9 +93,7 @@ declare global { archiveTweet: (accountID: number, tweetID: string) => Promise; archiveTweetCheckDate: (accountID: number, tweetID: string) => Promise; archiveBuild: (accountID: number) => Promise; - syncProgress: (accountID: number, progressJSON: string) => void; - openFolder: (accountID: number, folderName: string) => void; - getArchiveInfo: (accountID: number) => Promise; + syncProgress: (accountID: number, progressJSON: string) => Promise; resetRateLimitInfo: (accountID: number) => Promise; isRateLimited: (accountID: number) => Promise; getProgress: (accountID: number) => Promise; @@ -111,10 +116,22 @@ declare global { importXArchive: (accountID: number, archivePath: string, dataType: string) => Promise; getCookie: (accountID: number, name: string) => Promise; getConfig: (accountID: number, key: string) => Promise; - setConfig: (accountID: number, key: string, value: string) => void; + setConfig: (accountID: number, key: string, value: string) => Promise; }; - onPowerMonitorSuspend: (callback: () => void) => void; - onPowerMonitorResume: (callback: () => void) => void; + Facebook: { + resetProgress: (accountID: number) => Promise; + createJobs: (accountID: number, jobTypes: string[]) => Promise; + updateJob: (accountID: number, jobJSON: string) => Promise; + archiveBuild: (accountID: number) => Promise; + syncProgress: (accountID: number, progressJSON: string) => Promise; + getProgress: (accountID: number) => Promise; + getCookie: (accountID: number, name: string) => Promise; + getProfileImageDataURI: (accountID: number, profilePictureURI: string) => Promise; + getConfig: (accountID: number, key: string) => Promise; + setConfig: (accountID: number, key: string, value: string) => Promise; + }, + onPowerMonitorSuspend: (callback: () => void) => Promise; + onPowerMonitorResume: (callback: () => void) => Promise; }; } } diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index e2f1f7b8..5f6465fb 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -6,6 +6,7 @@ export const stubElectron = () => { getPlatform: cy.stub().resolves('win32'), getAPIURL: cy.stub().resolves('https://api.example.com'), getDashURL: cy.stub().resolves('https://dash.example.com'), + isFeatureEnabled: cy.stub().resolves(false), trackEvent: cy.stub().resolves('tracked'), shouldOpenDevtools: cy.stub().resolves(false), showMessage: cy.stub(), @@ -36,6 +37,8 @@ export const stubElectron = () => { archive: { isPageAlreadySaved: cy.stub().resolves(false), savePage: cy.stub().resolves(false), + openFolder: cy.stub(), + getInfo: cy.stub().resolves({}), }, X: { resetProgress: cy.stub().resolves({}), @@ -58,8 +61,6 @@ export const stubElectron = () => { archiveTweetCheckDate: cy.stub().resolves(), archiveBuild: cy.stub().resolves(), syncProgress: cy.stub(), - openFolder: cy.stub(), - getArchiveInfo: cy.stub().resolves({}), resetRateLimitInfo: cy.stub().resolves(), isRateLimited: cy.stub().resolves({}), getProgress: cy.stub().resolves({}), @@ -84,6 +85,18 @@ export const stubElectron = () => { getConfig: cy.stub().resolves(null), setConfig: cy.stub(), }, + Facebook: { + resetProgress: cy.stub(), + createJobs: cy.stub(), + updateJob: cy.stub(), + archiveBuild: cy.stub(), + syncProgress: cy.stub(), + getProgress: cy.stub(), + getCookie: cy.stub(), + getProfileImageDataURI: cy.stub(), + getConfig: cy.stub(), + setConfig: cy.stub(), + }, onPowerMonitorSuspend: cy.stub(), onPowerMonitorResume: cy.stub(), }; diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 2eac9478..0e5903cb 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -13,6 +13,7 @@ export const PlausibleEvents = Object.freeze({ AUTOMATION_ERROR_REPORT_SUBMITTED: 'Automation Error Report Submitted', AUTOMATION_ERROR_REPORT_NOT_SUBMITTED: 'Automation Error Report Not Submitted', AUTOMATION_ERROR_REPORT_ERROR: 'Automation Error Report Error', + X_USER_SIGNED_IN: 'X User Signed In', X_JOB_STARTED_LOGIN: 'X Job Started: login', X_JOB_STARTED_INDEX_TWEETS: 'X Job Started: indexTweets', @@ -28,4 +29,11 @@ export const PlausibleEvents = Object.freeze({ X_JOB_STARTED_DELETE_DMS: 'X Job Started: deleteDMs', X_JOB_STARTED_ARCHIVE_BUILD: 'X Job Started: archiveBuild', X_JOB_STARTED_UNFOLLOW_EVERYONE: 'X Job Started: unfollowEveryone', + + FACEBOOK_USER_SIGNED_IN: 'Facebook User Signed In', + FACEBOOK_JOB_STARTED_LOGIN: 'Facebook Job Started: login', + FACEBOOK_JOB_STARTED_SAVE_POSTS: 'Facebook Job Started: savePosts', + FACEBOOK_JOB_STARTED_SAVE_POSTS_HTML: 'Facebook Job Started: savePostsHTML', + FACEBOOK_JOB_STARTED_DELETE_POSTS: 'Facebook Job Started: deletePosts', + FACEBOOK_JOB_STARTED_ARCHIVE_BUILD: 'Facebook Job Started: archiveBuild', }); diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 84d14bb5..77b14529 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -56,9 +56,9 @@ export async function getDeviceInfo(): Promise { export function getAccountIcon(accountType: string): string { switch (accountType) { case "X": - // Not using the real X logo to avoid trademark issues - // return "fa-brands fa-x-twitter"; - return "fa-solid fa-xmark"; + return "fa-brands fa-x-twitter"; + case "Facebook": + return "fa-brands fa-facebook"; case "Bluesky": return "fa-brands fa-bluesky"; default: @@ -124,3 +124,11 @@ export const formattedDatetime = (date: string): string => { }; return new Date(date).toLocaleString('en-US', options); } + +export const showQuestionOpenModePremiumFeature = async (): Promise => { + return await window.electron.showQuestion( + "You're about to run a job that normally requires Premium access, but you're running Cyd in open source developer mode, so you don't have to authenticate with the Cyd server to use these features.\n\nIf you're not contributing to Cyd, please support the project by paying for a Premium plan.", + "Continue", + "Cancel" + ); +} \ No newline at end of file diff --git a/src/renderer/src/util_facebook.ts b/src/renderer/src/util_facebook.ts new file mode 100644 index 00000000..fe4bfd81 --- /dev/null +++ b/src/renderer/src/util_facebook.ts @@ -0,0 +1,17 @@ +import CydAPIClient from '../../cyd-api-client'; +import type { DeviceInfo } from './types'; +import { FacebookAccount } from '../../shared_types'; + +export async function facebookHasSomeData(_accountID: number): Promise { + // TODO: implement + return false; +} + +export async function facebookRequiresPremium(_facebookAccount: FacebookAccount): Promise { + // TODO: implement + return false; +} + +export async function facebookPostProgress(_apiClient: CydAPIClient, _deviceInfo: DeviceInfo | null, _accountID: number) { + // TODO: implement +} diff --git a/src/renderer/src/view_models/BaseViewModel.ts b/src/renderer/src/view_models/BaseViewModel.ts index efe9d22f..34a6f418 100644 --- a/src/renderer/src/view_models/BaseViewModel.ts +++ b/src/renderer/src/view_models/BaseViewModel.ts @@ -226,6 +226,15 @@ export class BaseViewModel { case "X": username = this.account?.xAccount?.username ? this.account?.xAccount.username : ""; break; + case "Facebook": + if (this.account?.facebookAccount?.accountID && this.account?.facebookAccount?.name) { + username = this.account?.facebookAccount.accountID + " " + this.account?.facebookAccount.name; + } else if (this.account?.facebookAccount?.accountID) { + username = this.account?.facebookAccount.accountID; + } else if (this.account?.facebookAccount?.name) { + username = this.account?.facebookAccount.name; + } + break; default: break; } diff --git a/src/renderer/src/view_models/FacebookViewModel.test.ts b/src/renderer/src/view_models/FacebookViewModel.test.ts new file mode 100644 index 00000000..0d63f912 --- /dev/null +++ b/src/renderer/src/view_models/FacebookViewModel.test.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import path from 'path'; + +import { test, expect } from 'vitest'; +import { + CurrentUserInitialData, + findCurrentUserInitialData, + findProfilePictureURI +} from './FacebookViewModel'; + +test('findCurrentUserInitialData() successfully finds CurrentUserInitialData', async () => { + const faceDataItemsJSON = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'testdata', 'facebook', 'FacebookDataItems.json'), 'utf8'); + const facebookDataItems = JSON.parse(faceDataItemsJSON); + const userData: CurrentUserInitialData | null = findCurrentUserInitialData(facebookDataItems); + expect(userData).toEqual({ + "ACCOUNT_ID": "61572760629627", + "USER_ID": "61572760629627", + "NAME": "Ethan Crosswell", + "SHORT_NAME": "Ethan", + "IS_BUSINESS_PERSON_ACCOUNT": false, + "HAS_SECONDARY_BUSINESS_PERSON": false, + "IS_FACEBOOK_WORK_ACCOUNT": false, + "IS_INSTAGRAM_BUSINESS_PERSON": false, + "IS_MESSENGER_ONLY_USER": false, + "IS_DEACTIVATED_ALLOWED_ON_MESSENGER": false, + "IS_MESSENGER_CALL_GUEST_USER": false, + "IS_WORK_MESSENGER_CALL_GUEST_USER": false, + "IS_WORKROOMS_USER": false, + "APP_ID": "2220391788200892", + "IS_BUSINESS_DOMAIN": false + }); +}) + +test('findProfilePictureURI() successfully finds the profile picture URI', async () => { + const faceDataItemsJSON = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'testdata', 'facebook', 'FacebookDataItems.json'), 'utf8'); + const facebookDataItems = JSON.parse(faceDataItemsJSON); + const profilePictureURI: string | null = findProfilePictureURI(facebookDataItems); + expect(profilePictureURI).toEqual("https://scontent.fsac1-2.fna.fbcdn.net/v/t39.30808-1/476279280_122101936418758687_8298574481110740604_n.jpg?stp=c434.0.1080.1080a_cp0_dst-jpg_s80x80_tt6&_nc_cat=105&ccb=1-7&_nc_sid=e99d92&_nc_ohc=pIxGW_rtPb4Q7kNvgH-lasu&_nc_oc=AditzkgBRRzY8wq4iVBGX4zS6csLNqI_etFbqsc_Vfmq4NIiPWL2sDxJd-iMlw6k22I&_nc_zt=24&_nc_ht=scontent.fsac1-2.fna&_nc_gid=ANXdZFZVCbQsbR38YXNk10r&oh=00_AYAR9jJI57lhGRMB5jmY4gmvBvCU9SHC6rU0vILNWIDUAQ&oe=67AC6A9A"); +}) diff --git a/src/renderer/src/view_models/FacebookViewModel.ts b/src/renderer/src/view_models/FacebookViewModel.ts new file mode 100644 index 00000000..cc456567 --- /dev/null +++ b/src/renderer/src/view_models/FacebookViewModel.ts @@ -0,0 +1,555 @@ +import { WebviewTag } from 'electron'; +import { BaseViewModel, InternetDownError, URLChangedError } from './BaseViewModel'; +import { + FacebookJob, + FacebookProgress, + emptyFacebookProgress +} from '../../../shared_types'; +import { PlausibleEvents } from "../types"; +import { AutomationErrorType } from '../automation_errors'; +import { facebookHasSomeData } from '../util_facebook'; + +export enum State { + Login = "Login", + + WizardStart = "WizardStart", + + WizardImportOrBuild = "WizardImportOrBuild", + WizardImportOrBuildDisplay = "WizardImportOrBuildDisplay", + + WizardImportStart = "WizardImportStart", + WizardImportStartDisplay = "WizardImportStartDisplay", + WizardImportDownload = "WizardImportDownload", + WizardImportDownloadDisplay = "WizardImportDownloadDisplay", + WizardImporting = "WizardImporting", + WizardImportingDisplay = "WizardImportingDisplay", + + WizardBuildOptions = "WizardBuildOptions", + WizardBuildOptionsDisplay = "WizardBuildOptionsDisplay", + + WizardCheckPremium = "WizardCheckPremium", + WizardCheckPremiumDisplay = "WizardCheckPremiumDisplay", + + RunJobs = "RunJobs", + + FinishedRunningJobs = "FinishedRunningJobs", + FinishedRunningJobsDisplay = "FinishedRunningJobsDisplay", + + Debug = "Debug", +} + +export type FacebookViewModelState = { + state: State; + action: string; + actionString: string; + progress: FacebookProgress; + jobs: FacebookJob[]; + currentJobIndex: number; +} + +// For traversing the Facebook JSON data embedded in the HTML + +interface FacebookDataItem { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export interface CurrentUserInitialData { + ACCOUNT_ID: string; + USER_ID: string; + NAME: string; + [key: string]: unknown; +} + +export function findCurrentUserInitialData(data: unknown): CurrentUserInitialData | null { + // If the current item is an array, iterate through its elements + if (Array.isArray(data)) { + for (const item of data) { + // Check if the first element is "CurrentUserInitialData" + if (Array.isArray(item) && item[0] === "CurrentUserInitialData") { + // Check if the third element is an object with the required keys + if ( + item[2] && + typeof item[2] === "object" && + "ACCOUNT_ID" in item[2] && + "USER_ID" in item[2] && + "NAME" in item[2] + ) { + return item[2] as CurrentUserInitialData; + } + } + // Recursively search nested arrays + const result = findCurrentUserInitialData(item); + if (result) { + return result; + } + } + } + // If the current item is an object, recursively search its values + else if (typeof data === "object" && data !== null) { + // Use type assertion for object iteration + const obj = data as Record; + for (const key of Object.keys(obj)) { + // Safe property check + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const result = findCurrentUserInitialData(obj[key]); + if (result) { + return result; + } + } + } + } + // If nothing is found, return null + return null; +} + +export function findProfilePictureURI(data: unknown): string | null { + // Handle arrays by checking each element + if (Array.isArray(data)) { + for (const item of data) { + const result = findProfilePictureURI(item); + if (result) return result; + } + } + // Handle objects + else if (typeof data === "object" && data !== null) { + const obj = data as Record; + + // Check if this is the actor object we're looking for + if (obj.actor && typeof obj.actor === "object") { + const actor = obj.actor as Record; + if (actor.__typename === "User" && + actor.profile_picture && + typeof actor.profile_picture === "object") { + const profilePicture = actor.profile_picture as Record; + if (typeof profilePicture.uri === "string") { + return profilePicture.uri; + } + } + } + + // Recursively search through all object properties + for (const key of Object.keys(obj)) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const result = findProfilePictureURI(obj[key]); + if (result) return result; + } + } + } + return null; +} + + +export class FacebookViewModel extends BaseViewModel { + public progress: FacebookProgress = emptyFacebookProgress(); + public jobs: FacebookJob[] = []; + public currentJobIndex: number = 0; + + // Variables related to debugging + public debugAutopauseEndOfStep: boolean = false; + + async init(webview: WebviewTag) { + if (this.account && this.account?.facebookAccount && this.account?.facebookAccount.accountID) { + this.state = State.WizardStart; + } else { + this.state = State.Login; + } + + this.currentJobIndex = 0; + + super.init(webview); + } + + async defineJobs() { + let shouldBuildArchive = false; + const hasSomeData = await facebookHasSomeData(this.account?.id); + + const jobTypes = []; + jobTypes.push("login"); + + if (this.account?.facebookAccount?.saveMyData) { + shouldBuildArchive = true; + if (this.account?.facebookAccount?.savePosts) { + jobTypes.push("savePosts"); + if (this.account?.facebookAccount?.savePostsHTML) { + jobTypes.push("savePostsHTML"); + } + } + } + + if (this.account?.facebookAccount?.deleteMyData) { + if (hasSomeData && this.account?.facebookAccount?.deletePosts) { + jobTypes.push("deletePosts"); + shouldBuildArchive = true; + } + } + + if (shouldBuildArchive) { + jobTypes.push("archiveBuild"); + } + + try { + this.jobs = await window.electron.Facebook.createJobs(this.account?.id, jobTypes); + this.log("defineJobs", JSON.parse(JSON.stringify(this.jobs))); + } catch (e) { + await this.error(AutomationErrorType.facebook_unknownError, { + exception: (e as Error).toString() + }, { + currentURL: this.webview?.getURL() + }); + return; + } + } + + async reset() { + this.progress = emptyFacebookProgress(); + this.jobs = []; + this.state = State.WizardStart; + } + + async finishJob(jobIndex: number) { + const finishedAt = new Date(); + this.jobs[jobIndex].finishedAt = finishedAt; + this.jobs[jobIndex].status = "finished"; + this.jobs[jobIndex].progressJSON = JSON.stringify(this.progress); + await window.electron.Facebook.updateJob(this.account?.id, JSON.stringify(this.jobs[jobIndex])); + await window.electron.Facebook.setConfig( + this.account?.id, + `lastFinishedJob_${this.jobs[jobIndex].jobType}`, + finishedAt.toISOString() + ); + this.log("finishJob", this.jobs[jobIndex].jobType); + } + + async errorJob(jobIndex: number) { + this.jobs[jobIndex].finishedAt = new Date(); + this.jobs[jobIndex].status = "error"; + this.jobs[jobIndex].progressJSON = JSON.stringify(this.progress); + await window.electron.Facebook.updateJob(this.account?.id, JSON.stringify(this.jobs[jobIndex])); + this.log("errorJob", this.jobs[jobIndex].jobType); + } + + async syncProgress() { + await window.electron.Facebook.syncProgress(this.account?.id, JSON.stringify(this.progress)); + } + + async loadFacebookURL(url: string, expectedURLs: (string | RegExp)[] = [], redirectOk: boolean = false) { + this.log("loadFacebookURL", [url, expectedURLs, redirectOk]); + + // eslint-disable-next-line no-constant-condition + while (true) { + // Load the URL + try { + await this.loadURL(url); + this.log("loadFacebookURL", "URL loaded successfully"); + } catch (e) { + if (e instanceof InternetDownError) { + this.log("loadFacebookURL", "internet is down"); + this.emitter?.emit(`cancel-automation-${this.account?.id}`); + } else { + await this.error(AutomationErrorType.facebook_loadURLError, { + url: url, + exception: (e as Error).toString() + }, { + currentURL: this.webview?.getURL() + }); + } + break; + } + + // Did the URL change? + if (!redirectOk) { + this.log("loadFacebookURL", "checking if URL changed"); + const newURL = new URL(this.webview?.getURL() || ''); + const originalURL = new URL(url); + // Check if the URL has changed, ignoring query strings + // e.g. a change from https://www.facebook.com/ to https://www.facebook.com/?mx=2 is ok + if (newURL.origin + newURL.pathname !== originalURL.origin + originalURL.pathname) { + let changedToUnexpected = true; + for (const expectedURL of expectedURLs) { + if (typeof expectedURL === 'string' && newURL.toString().startsWith(expectedURL)) { + changedToUnexpected = false; + break; + } else if (expectedURL instanceof RegExp && expectedURL.test(newURL.toString())) { + changedToUnexpected = false; + break; + } + } + + if (changedToUnexpected) { + this.log("loadFacebookURL", `UNEXPECTED, URL change to ${this.webview?.getURL()}`); + throw new URLChangedError(url, this.webview?.getURL() || ''); + } else { + this.log("loadFacebookURL", `expected, URL change to ${this.webview?.getURL()}`); + } + } + } + + // TODO: handle Facebook rate limits + + // Finished successfully so break out of the loop + this.log("loadFacebookURL", "finished loading URL"); + break; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getFacebookDataFromHTML(): Promise { + this.log("getFacebookData"); + + // When loading the Facebook home page, there are dozens of ` + + + + \ No newline at end of file diff --git a/src/renderer/src/views/facebook/FacebookWizardImportOrBuildPage.vue b/src/renderer/src/views/facebook/FacebookWizardImportOrBuildPage.vue new file mode 100644 index 00000000..1018b6c8 --- /dev/null +++ b/src/renderer/src/views/facebook/FacebookWizardImportOrBuildPage.vue @@ -0,0 +1,77 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/src/views/facebook/FacebookWizardSidebar.vue b/src/renderer/src/views/facebook/FacebookWizardSidebar.vue new file mode 100644 index 00000000..d84c7fc5 --- /dev/null +++ b/src/renderer/src/views/facebook/FacebookWizardSidebar.vue @@ -0,0 +1,66 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/src/views/shared_components/AccountButton.vue b/src/renderer/src/views/shared_components/AccountButton.vue index c23ee542..a097c2ce 100644 --- a/src/renderer/src/views/shared_components/AccountButton.vue +++ b/src/renderer/src/views/shared_components/AccountButton.vue @@ -51,8 +51,16 @@ onUnmounted(async () => {
@@ -68,6 +76,15 @@ onUnmounted(async () => { @{{ props.account.xAccount?.username }} +