diff --git a/src/account_x.test.ts b/src/account_x.test.ts index b2e4e8c8..af7be693 100644 --- a/src/account_x.test.ts +++ b/src/account_x.test.ts @@ -9,7 +9,7 @@ import { XAPILegacyTweet, XAPIConversation, XAPIUser -} from './account_x_types'; +} from './account_x'; import { XTweetRow, XUserRow, diff --git a/src/account_x/index.ts b/src/account_x/index.ts new file mode 100644 index 00000000..151effcc --- /dev/null +++ b/src/account_x/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './x_account_controller'; +export * from './ipc'; diff --git a/src/account_x/ipc.ts b/src/account_x/ipc.ts new file mode 100644 index 00000000..3a0f7260 --- /dev/null +++ b/src/account_x/ipc.ts @@ -0,0 +1,430 @@ +import { ipcMain } from 'electron' + +import { XAccountController } from './x_account_controller'; + +import { + XAccount, + XJob, + XProgress, + XArchiveStartResponse, + XRateLimitInfo, + XIndexMessagesStartResponse, + XDeleteTweetsStartResponse, + XProgressInfo, + ResponseData, + XDatabaseStats, + XDeleteReviewStats, + XArchiveInfo, + XImportArchiveResponse +} from '../shared_types' +import { getMITMController } from '../mitm'; +import { packageExceptionForReport } from '../util' + +const controllers: Record = {}; + +const getXAccountController = (accountID: number): XAccountController => { + if (!controllers[accountID]) { + controllers[accountID] = new XAccountController(accountID, getMITMController(accountID)); + } + controllers[accountID].refreshAccount(); + return controllers[accountID]; +} + +export const defineIPCX = () => { + ipcMain.handle('X:resetProgress', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return controller.resetProgress(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:createJobs', async (_, accountID: number, jobTypes: string[]): Promise => { + try { + const controller = getXAccountController(accountID); + return controller.createJobs(jobTypes); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getLastFinishedJob', async (_, accountID: number, jobType: string): Promise => { + try { + const controller = getXAccountController(accountID); + return controller.getLastFinishedJob(jobType); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:updateJob', async (_, accountID: number, jobJSON: string) => { + try { + const controller = getXAccountController(accountID); + const job = JSON.parse(jobJSON) as XJob; + controller.updateJob(job); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexStart', async (_, accountID: number) => { + try { + const controller = getXAccountController(accountID); + await controller.indexStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexStop', async (_, accountID: number) => { + try { + const controller = getXAccountController(accountID); + await controller.indexStop(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexParseAllJSON', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexParseAllJSON(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexParseTweets', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexParseTweets(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexParseConversations', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexParseConversations(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexIsThereMore', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexIsThereMore(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:resetThereIsMore', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.resetThereIsMore(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexMessagesStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexMessagesStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexParseMessages', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.indexParseMessages(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:indexConversationFinished', async (_, accountID: number, conversationID: string): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.indexConversationFinished(conversationID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:archiveTweetsStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.archiveTweetsStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:archiveTweetsOutputPath', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.archiveTweetsOutputPath(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:archiveTweet', async (_, accountID: number, tweetID: string): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.archiveTweet(tweetID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:archiveTweetCheckDate', async (_, accountID: number, tweetID: string): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.archiveTweetCheckDate(tweetID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:archiveBuild', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.archiveBuild(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:syncProgress', async (_, accountID: number, progressJSON: string) => { + try { + const controller = getXAccountController(accountID); + await controller.syncProgress(progressJSON); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:openFolder', async (_, accountID: number, folderName: string) => { + try { + const controller = getXAccountController(accountID); + await controller.openFolder(folderName); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getArchiveInfo', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getArchiveInfo(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:resetRateLimitInfo', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.resetRateLimitInfo(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:isRateLimited', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.isRateLimited(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getProgress', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getProgress(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getProgressInfo', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getProgressInfo(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getDatabaseStats', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getDatabaseStats(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getDeleteReviewStats', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getDeleteReviewStats(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:saveProfileImage', async (_, accountID: number, url: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.saveProfileImage(url); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getLatestResponseData', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getLatestResponseData(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteTweetsStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteTweetsStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteTweetsCountNotArchived', async (_, accountID: number, total: boolean): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteTweetsCountNotArchived(total); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteRetweetsStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteRetweetsStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteLikesStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteLikesStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteBookmarksStart', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.deleteBookmarksStart(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteTweet', async (_, accountID: number, tweetID: string, deleteType: string): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.deleteTweet(tweetID, deleteType); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteDMsMarkAllDeleted', async (_, accountID: number): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.deleteDMsMarkAllDeleted(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:unzipXArchive', async (_, accountID: number, archivePath: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.unzipXArchive(archivePath); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:deleteUnzippedXArchive', async (_, accountID: number, archivePath: string): Promise => { + try { + const controller = getXAccountController(accountID); + await controller.deleteUnzippedXArchive(archivePath); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:verifyXArchive', async (_, accountID: number, archivePath: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.verifyXArchive(archivePath); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:importXArchive', async (_, accountID: number, archivePath: string, dataType: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.importXArchive(archivePath, dataType); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getCookie', async (_, accountID: number, name: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getCookie(name); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:getConfig', async (_, accountID: number, key: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.getConfig(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('X:setConfig', async (_, accountID: number, key: string, value: string): Promise => { + try { + const controller = getXAccountController(accountID); + return await controller.setConfig(key, value); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); +}; diff --git a/src/account_x_types.ts b/src/account_x/types.ts similarity index 79% rename from src/account_x_types.ts rename to src/account_x/types.ts index 5780166e..80c28970 100644 --- a/src/account_x_types.ts +++ b/src/account_x/types.ts @@ -1,5 +1,125 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { XJob, XTweetItem, XTweetItemArchive } from '../shared_types' + +// Models + +export interface XJobRow { + id: number; + jobType: string; + status: string; + scheduledAt: string; + startedAt: string | null; + finishedAt: string | null; + progressJSON: string | null; + error: string | null; +} + +export interface XTweetRow { + id: number; + username: string; + tweetID: string; + conversationID: string; + createdAt: string; + likeCount: number; + quoteCount: number; + replyCount: number; + retweetCount: number; + isLiked: boolean; + isRetweeted: boolean; + isBookmarked: boolean; + text: string; + path: string; + addedToDatabaseAt: string; + archivedAt: string | null; + deletedTweetAt: string | null; + deletedRetweetAt: string | null; + deletedLikeAt: string | null; + deletedBookmarkAt: string | null; +} + +export interface XUserRow { + id: number; + userID: string; + name: string | null; + screenName: string; + profileImageDataURI: string | null; +} + +export interface XConversationRow { + id: number; + conversationID: string; + type: string; + sortTimestamp: string | null; + minEntryID: string | null; + maxEntryID: string | null; + isTrusted: boolean | null; + shouldIndexMessages: boolean | null; + addedToDatabaseAt: string; + updatedInDatabaseAt: string | null; + deletedAt: string | null; +} + +export interface XConversationParticipantRow { + id: number; + conversationID: string; + userID: string; +} + +export interface XMessageRow { + id: number; + messageID: string; + conversationID: string; + createdAt: string; + senderID: string; + text: string; + deletedAt: string | null; +} + +// Converters + +export function convertXJobRowToXJob(row: XJobRow): XJob { + 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, + }; +} + +export function convertTweetRowToXTweetItem(row: XTweetRow): XTweetItem { + return { + id: row.tweetID, + t: row.text, + l: row.likeCount, + r: row.retweetCount, + d: row.createdAt, + }; +} + +function formatDateToYYYYMMDD(dateString: string): string { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function convertTweetRowToXTweetItemArchive(row: XTweetRow): XTweetItemArchive { + return { + url: `https://x.com/${row.path}`, + tweetID: row.tweetID, + basename: `${formatDateToYYYYMMDD(row.createdAt)}_${row.tweetID}`, + username: row.username + }; +} + +// X API types + // Index tweets export interface XAPILegacyTweet { @@ -424,4 +544,4 @@ export interface XArchiveDMConversation { conversationId: string, messages: XArchiveDMMessage[], } -} \ No newline at end of file +} diff --git a/src/account_x.ts b/src/account_x/x_account_controller.ts similarity index 82% rename from src/account_x.ts rename to src/account_x/x_account_controller.ts index cd028541..6a35ffa8 100644 --- a/src/account_x.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, ipcMain, session, shell } from 'electron' +import { app, session, shell } from 'electron' import log from 'electron-log/main'; import Database from 'better-sqlite3' import { glob } from 'glob'; @@ -13,14 +13,12 @@ import { glob } from 'glob'; import { getResourcesPath, getAccountDataPath, - packageExceptionForReport, getTimestampDaysAgo -} from './util' +} from '../util' import { XAccount, XJob, XProgress, emptyXProgress, - XTweetItem, XTweetItemArchive, XArchiveStartResponse, emptyXArchiveStartResponse, XRateLimitInfo, emptyXRateLimitInfo, @@ -32,7 +30,7 @@ import { XDeleteReviewStats, emptyXDeleteReviewStats, XArchiveInfo, emptyXArchiveInfo, XImportArchiveResponse -} from './shared_types' +} from '../shared_types' import { runMigrations, getAccount, @@ -41,9 +39,19 @@ import { Sqlite3Count, getConfig, setConfig, -} from './database' -import { IMITMController, getMITMController } from './mitm'; +} from '../database' +import { IMITMController } from '../mitm'; import { + XJobRow, + XTweetRow, + XUserRow, + XConversationRow, + XMessageRow, + XConversationParticipantRow, + convertXJobRowToXJob, + convertTweetRowToXTweetItem, + convertTweetRowToXTweetItemArchive, + // X API types XAPILegacyUser, XAPILegacyTweet, XAPIData, @@ -65,121 +73,8 @@ import { isXArchiveLikeContainer, isXAPIBookmarksData, isXAPIData, - // XArchiveDMConversation, -} from './account_x_types' -import * as XArchiveTypes from '../archive-static-sites/x-archive/src/types'; - -function formatDateToYYYYMMDD(dateString: string): string { - const date = new Date(dateString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -export interface XJobRow { - id: number; - jobType: string; - status: string; - scheduledAt: string; - startedAt: string | null; - finishedAt: string | null; - progressJSON: string | null; - error: string | null; -} - -export interface XTweetRow { - id: number; - username: string; - tweetID: string; - conversationID: string; - createdAt: string; - likeCount: number; - quoteCount: number; - replyCount: number; - retweetCount: number; - isLiked: boolean; - isRetweeted: boolean; - isBookmarked: boolean; - text: string; - path: string; - addedToDatabaseAt: string; - archivedAt: string | null; - deletedTweetAt: string | null; - deletedRetweetAt: string | null; - deletedLikeAt: string | null; - deletedBookmarkAt: string | null; -} - -export interface XUserRow { - id: number; - userID: string; - name: string | null; - screenName: string; - profileImageDataURI: string | null; -} - -export interface XConversationRow { - id: number; - conversationID: string; - type: string; - sortTimestamp: string | null; - minEntryID: string | null; - maxEntryID: string | null; - isTrusted: boolean | null; - shouldIndexMessages: boolean | null; - addedToDatabaseAt: string; - updatedInDatabaseAt: string | null; - deletedAt: string | null; -} - -export interface XConversationParticipantRow { - id: number; - conversationID: string; - userID: string; -} - -export interface XMessageRow { - id: number; - messageID: string; - conversationID: string; - createdAt: string; - senderID: string; - text: string; - deletedAt: string | null; -} - -function convertXJobRowToXJob(row: XJobRow): XJob { - 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, - }; -} - -function convertTweetRowToXTweetItem(row: XTweetRow): XTweetItem { - return { - id: row.tweetID, - t: row.text, - l: row.likeCount, - r: row.retweetCount, - d: row.createdAt, - }; -} - -function convertTweetRowToXTweetItemArchive(row: XTweetRow): XTweetItemArchive { - return { - url: `https://x.com/${row.path}`, - tweetID: row.tweetID, - basename: `${formatDateToYYYYMMDD(row.createdAt)}_${row.tweetID}`, - username: row.username - }; -} +} from './types' +import * as XArchiveTypes from '../../archive-static-sites/x-archive/src/types'; export class XAccountController { private accountUUID: string = ""; @@ -2248,412 +2143,3 @@ export class XAccountController { return setConfig(key, value, this.db); } } - -const controllers: Record = {}; - -const getXAccountController = (accountID: number): XAccountController => { - if (!controllers[accountID]) { - controllers[accountID] = new XAccountController(accountID, getMITMController(accountID)); - } - controllers[accountID].refreshAccount(); - return controllers[accountID]; -} - -export const defineIPCX = () => { - ipcMain.handle('X:resetProgress', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return controller.resetProgress(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:createJobs', async (_, accountID: number, jobTypes: string[]): Promise => { - try { - const controller = getXAccountController(accountID); - return controller.createJobs(jobTypes); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getLastFinishedJob', async (_, accountID: number, jobType: string): Promise => { - try { - const controller = getXAccountController(accountID); - return controller.getLastFinishedJob(jobType); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:updateJob', async (_, accountID: number, jobJSON: string) => { - try { - const controller = getXAccountController(accountID); - const job = JSON.parse(jobJSON) as XJob; - controller.updateJob(job); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexStart', async (_, accountID: number) => { - try { - const controller = getXAccountController(accountID); - await controller.indexStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexStop', async (_, accountID: number) => { - try { - const controller = getXAccountController(accountID); - await controller.indexStop(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexParseAllJSON', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexParseAllJSON(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexParseTweets', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexParseTweets(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexParseConversations', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexParseConversations(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexIsThereMore', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexIsThereMore(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:resetThereIsMore', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.resetThereIsMore(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexMessagesStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexMessagesStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexParseMessages', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.indexParseMessages(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:indexConversationFinished', async (_, accountID: number, conversationID: string): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.indexConversationFinished(conversationID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:archiveTweetsStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.archiveTweetsStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:archiveTweetsOutputPath', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.archiveTweetsOutputPath(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:archiveTweet', async (_, accountID: number, tweetID: string): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.archiveTweet(tweetID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:archiveTweetCheckDate', async (_, accountID: number, tweetID: string): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.archiveTweetCheckDate(tweetID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:archiveBuild', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.archiveBuild(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:syncProgress', async (_, accountID: number, progressJSON: string) => { - try { - const controller = getXAccountController(accountID); - await controller.syncProgress(progressJSON); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:openFolder', async (_, accountID: number, folderName: string) => { - try { - const controller = getXAccountController(accountID); - await controller.openFolder(folderName); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getArchiveInfo', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getArchiveInfo(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:resetRateLimitInfo', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.resetRateLimitInfo(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:isRateLimited', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.isRateLimited(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getProgress', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getProgress(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getProgressInfo', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getProgressInfo(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getDatabaseStats', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getDatabaseStats(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getDeleteReviewStats', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getDeleteReviewStats(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:saveProfileImage', async (_, accountID: number, url: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.saveProfileImage(url); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getLatestResponseData', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getLatestResponseData(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteTweetsStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteTweetsStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteTweetsCountNotArchived', async (_, accountID: number, total: boolean): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteTweetsCountNotArchived(total); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteRetweetsStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteRetweetsStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteLikesStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteLikesStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteBookmarksStart', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteBookmarksStart(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteTweet', async (_, accountID: number, tweetID: string, deleteType: string): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.deleteTweet(tweetID, deleteType); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteDMsMarkAllDeleted', async (_, accountID: number): Promise => { - try { - const controller = getXAccountController(accountID); - await controller.deleteDMsMarkAllDeleted(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:unzipXArchive', async (_, accountID: number, archivePath: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.unzipXArchive(archivePath); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:deleteUnzippedXArchive', async (_, accountID: number, archivePath: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.deleteUnzippedXArchive(archivePath); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:verifyXArchive', async (_, accountID: number, archivePath: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.verifyXArchive(archivePath); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:importXArchive', async (_, accountID: number, archivePath: string, dataType: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.importXArchive(archivePath, dataType); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getCookie', async (_, accountID: number, name: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getCookie(name); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:getConfig', async (_, accountID: number, key: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.getConfig(key); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('X:setConfig', async (_, accountID: number, key: string, value: string): Promise => { - try { - const controller = getXAccountController(accountID); - return await controller.setConfig(key, value); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); -}; diff --git a/src/database.test.ts b/src/database.test.ts index 6a5c23d3..0593df91 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -43,7 +43,7 @@ afterEach(() => { // database tests -test("config, account, and xAccount tables should be created", async () => { +test("config, account, and xAccount, blueskyAccount tables should be created", async () => { const db = database.getMainDatabase(); const tables = await database.exec( db, @@ -55,6 +55,7 @@ test("config, account, and xAccount tables should be created", async () => { expect.objectContaining({ name: 'config' }), expect.objectContaining({ name: 'account' }), expect.objectContaining({ name: 'xAccount' }), + expect.objectContaining({ name: 'blueskyAccount' }), ])); }) @@ -136,6 +137,66 @@ test("getXAccounts should retrieve all XAccounts", () => { expect(accounts).toEqual(expect.arrayContaining([xAccount1, xAccount2])); }); +test("createBlueskyAccount should create a new BlueskyAccount", () => { + const blueskyAccount = database.createBlueskyAccount(); + expect(blueskyAccount).toHaveProperty('id'); + expect(blueskyAccount).toHaveProperty('createdAt'); + expect(blueskyAccount).toHaveProperty('updatedAt'); + expect(blueskyAccount).toHaveProperty('accessedAt'); + expect(blueskyAccount).toHaveProperty('username'); + expect(blueskyAccount).toHaveProperty('profileImageDataURI'); + expect(blueskyAccount).toHaveProperty('saveMyData'); + expect(blueskyAccount).toHaveProperty('deleteMyData'); + expect(blueskyAccount).toHaveProperty('archivePosts'); + expect(blueskyAccount).toHaveProperty('archivePostsHTML'); + expect(blueskyAccount).toHaveProperty('archiveLikes'); + expect(blueskyAccount).toHaveProperty('deletePosts'); + expect(blueskyAccount).toHaveProperty('deletePostsDaysOld'); + expect(blueskyAccount).toHaveProperty('deletePostsDaysOldEnabled'); + expect(blueskyAccount).toHaveProperty('deletePostsLikesThresholdEnabled'); + expect(blueskyAccount).toHaveProperty('deletePostsLikesThreshold'); + expect(blueskyAccount).toHaveProperty('deletePostsRepostsThresholdEnabled'); + expect(blueskyAccount).toHaveProperty('deletePostsRepostsThreshold'); + expect(blueskyAccount).toHaveProperty('deleteReposts'); + expect(blueskyAccount).toHaveProperty('deleteRepostsDaysOld'); + expect(blueskyAccount).toHaveProperty('deleteRepostsDaysOldEnabled'); + expect(blueskyAccount).toHaveProperty('deleteLikes'); + expect(blueskyAccount).toHaveProperty('deleteLikesDaysOld'); + expect(blueskyAccount).toHaveProperty('deleteLikesDaysOldEnabled'); + expect(blueskyAccount).toHaveProperty('followingCount'); + expect(blueskyAccount).toHaveProperty('followersCount'); + expect(blueskyAccount).toHaveProperty('postsCount'); + expect(blueskyAccount).toHaveProperty('likesCount'); +}); + +test("saveBlueskyAccount should update an existing BlueskyAccount", () => { + const blueskyAccount = database.createBlueskyAccount(); + blueskyAccount.username = 'newUsername'; + database.saveBlueskyAccount(blueskyAccount); + + const db = database.getMainDatabase(); + const result = database.exec(db, 'SELECT * FROM blueskyAccount WHERE id = ?', [blueskyAccount.id], 'get'); + expect(result).toEqual(expect.objectContaining({ username: 'newUsername' })); +}); + +test("getBlueskyAccount should retrieve the correct BlueskyAccount", () => { + const blueskyAccount = database.createBlueskyAccount(); + database.saveBlueskyAccount(blueskyAccount); + + const retrievedAccount = database.getBlueskyAccount(blueskyAccount.id); + expect(retrievedAccount).toEqual(blueskyAccount); +}); + +test("getBlueskyAccounts should retrieve all BlueskyAccounts", () => { + const blueskyAccount1 = database.createBlueskyAccount(); + const blueskyAccount2 = database.createBlueskyAccount(); + database.saveBlueskyAccount(blueskyAccount1); + database.saveBlueskyAccount(blueskyAccount2); + + const accounts = database.getBlueskyAccounts(); + expect(accounts).toEqual(expect.arrayContaining([blueskyAccount1, blueskyAccount2])); +}); + test("createAccount should create a new Account", () => { const account = database.createAccount(); expect(account).toHaveProperty('id'); diff --git a/src/database.ts b/src/database.ts deleted file mode 100644 index bd70d9dc..00000000 --- a/src/database.ts +++ /dev/null @@ -1,810 +0,0 @@ -import path from "path" -import crypto from 'crypto'; -import os from 'os'; - -import log from 'electron-log/main'; -import Database from 'better-sqlite3' -import { app, ipcMain, session } from 'electron'; - -import { getSettingsPath, packageExceptionForReport } from "./util" -import { ErrorReport, Account, XAccount } from './shared_types' - -export type Migration = { - name: string; - sql: string[]; -}; - -let mainDatabase: Database.Database | null = null; - -export const getMainDatabase = () => { - if (mainDatabase) { - return mainDatabase; - } - - const dbPath = path.join(getSettingsPath(), 'db.sqlite'); - mainDatabase = new Database(dbPath, {}); - mainDatabase.pragma('journal_mode = WAL'); - return mainDatabase; -} - -export const closeMainDatabase = () => { - if (mainDatabase) { - mainDatabase.close(); - mainDatabase = null; - } -} - -export const runMigrations = (db: Database.Database, migrations: Migration[]) => { - // Create a migrations table if necessary - const migrationsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").get(); - if (!migrationsTable) { - // Create the migrations table - log.debug("Creating migrations table"); - db.prepare(`CREATE TABLE migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - runAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP -)`).run(); - } - - // Apply the migrations in order - for (const migration of migrations) { - const migrationRecord = db.prepare("SELECT * FROM migrations WHERE name = ?").get(migration.name); - if (!migrationRecord) { - log.info(`Running migration: ${migration.name}`); - for (const sql of migration.sql) { - db.exec(sql); - } - db.prepare("INSERT INTO migrations (name) VALUES (?)").run(migration.name); - } - } -} - -export const runMainMigrations = () => { - runMigrations(getMainDatabase(), [ - // Create the tables - { - name: "initial", - sql: [ - `CREATE TABLE config ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - value TEXT NOT NULL -);`, `CREATE TABLE account ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL DEFAULT 'unknown', - sortOrder INTEGER NOT NULL DEFAULT 0, - xAccountId INTEGER DEFAULT NULL, - uuid TEXT NOT NULL -);`, `CREATE TABLE xAccount ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, - updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, - accessedAt DATETIME DEFAULT CURRENT_TIMESTAMP, - username TEXT, - profileImageDataURI TEXT, - saveMyData BOOLEAN DEFAULT 1, - deleteMyData BOOLEAN DEFAULT 0, - archiveTweets BOOLEAN DEFAULT 1, - archiveTweetsHTML BOOLEAN DEFAULT 0, - archiveLikes BOOLEAN DEFAULT 1, - archiveDMs BOOLEAN DEFAULT 1, - deleteTweets BOOLEAN DEFAULT 1, - deleteTweetsDaysOld INTEGER DEFAULT 0, - deleteTweetsLikesThresholdEnabled BOOLEAN DEFAULT 0, - deleteTweetsLikesThreshold INTEGER DEFAULT 20, - deleteTweetsRetweetsThresholdEnabled BOOLEAN DEFAULT 0, - deleteTweetsRetweetsThreshold INTEGER DEFAULT 20, - deleteRetweets BOOLEAN DEFAULT 1, - deleteRetweetsDaysOld INTEGER DEFAULT 0, - deleteLikes BOOLEAN DEFAULT 0, - deleteLikesDaysOld INTEGER DEFAULT 0, - deleteDMs BOOLEAN DEFAULT 0 -);`, - ] - }, - // Add importFromArchive, followingCount, follwersCount, tweetsCount, likesCount to xAccount - { - name: "add importFromArchive, followingCount, follwersCount, tweetsCount, likesCount to xAccount", - sql: [ - `ALTER TABLE xAccount ADD COLUMN importFromArchive BOOLEAN DEFAULT 1;`, - `ALTER TABLE xAccount ADD COLUMN followingCount INTEGER DEFAULT 0;`, - `ALTER TABLE xAccount ADD COLUMN followersCount INTEGER DEFAULT 0;`, - `ALTER TABLE xAccount ADD COLUMN tweetsCount INTEGER DEFAULT -1;`, - `ALTER TABLE xAccount ADD COLUMN likesCount INTEGER DEFAULT -1;`, - ] - }, - // Add unfollowEveryone to xAccount - { - name: "add unfollowEveryone to xAccount", - sql: [ - `ALTER TABLE xAccount ADD COLUMN unfollowEveryone BOOLEAN DEFAULT 1;`, - ] - }, - // Add deleteTweetsDaysOldEnabled, deleteRetweetsDaysOldEnabled, deleteLikesDaysOldEnabled to xAccount - { - name: "add deleteTweetsDaysOldEnabled, deleteRetweetsDaysOldEnabled, deleteLikesDaysOldEnabled to xAccount", - sql: [ - `ALTER TABLE xAccount ADD COLUMN deleteTweetsDaysOldEnabled BOOLEAN DEFAULT 0;`, - `ALTER TABLE xAccount ADD COLUMN deleteRetweetsDaysOldEnabled BOOLEAN DEFAULT 0;`, - `ALTER TABLE xAccount ADD COLUMN deleteLikesDaysOldEnabled BOOLEAN DEFAULT 0;`, - ] - }, - // Add errorReport table. Status can be "new", "submitted", and "dismissed" - { - name: "add errorReport table", - sql: [ - `CREATE TABLE errorReport ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, - accountID INTEGER DEFAULT NULL, - appVersion TEXT DEFAULT NULL, - clientPlatform TEXT DEFAULT NULL, - accountType TEXT DEFAULT NULL, - errorReportType TEXT NOT NULL, - errorReportData TEXT DEFAULT NULL, - accountUsername TEXT DEFAULT NULL, - screenshotDataURI TEXT DEFAULT NULL, - sensitiveContextData TEXT DEFAULT NULL, - status TEXT DEFAULT 'new' -);`, - ] - }, - // Add archiveMyData to xAccount - { - name: "add archiveMyData to xAccount", - sql: [ - `ALTER TABLE xAccount ADD COLUMN archiveMyData BOOLEAN DEFAULT 0;`, - ] - }, - // Add archiveBookmarks, deleteBookmarks to xAccount - { - name: "add archiveBookmarks, deleteBookmarks to xAccount", - sql: [ - `ALTER TABLE xAccount ADD COLUMN archiveBookmarks BOOLEAN DEFAULT 1;`, - `ALTER TABLE xAccount ADD COLUMN deleteBookmarks BOOLEAN DEFAULT 0;`, - ] - }, - ]); -} - -export interface Sqlite3Info { - lastInsertRowid: number; - changes: number; -} - -export interface Sqlite3Count { - count: number; -} - -interface ConfigRow { - key: string; - value: string; -} - -interface AccountRow { - id: number; - type: string; - sortOrder: number; - xAccountId: number | null; - uuid: string; -} - -interface XAccountRow { - id: number; - createdAt: string; - updatedAt: string; - accessedAt: string; - username: string; - profileImageDataURI: string; - importFromArchive: boolean; - saveMyData: boolean; - deleteMyData: boolean; - archiveMyData: boolean; - archiveTweets: boolean; - archiveTweetsHTML: boolean; - archiveLikes: boolean; - archiveBookmarks: boolean; - archiveDMs: boolean; - deleteTweets: boolean; - deleteTweetsDaysOldEnabled: boolean; - deleteTweetsDaysOld: number; - deleteTweetsLikesThresholdEnabled: boolean; - deleteTweetsLikesThreshold: number; - deleteTweetsRetweetsThresholdEnabled: boolean; - deleteTweetsRetweetsThreshold: number; - deleteRetweets: boolean; - deleteRetweetsDaysOldEnabled: boolean; - deleteRetweetsDaysOld: number; - deleteLikes: boolean; - deleteBookmarks: number; - deleteDMs: boolean; - unfollowEveryone: boolean; - followingCount: number; - followersCount: number; - tweetsCount: number; - likesCount: number; -} - -export interface ErrorReportRow { - id: number; - createdAt: string; - accountID: number; - appVersion: string; - clientPlatform: string; - accountType: string; - errorReportType: string; - errorReportData: string; - accountUsername: string; - screenshotDataURI: string; - sensitiveContextData: string; - status: string; // "new", "submitted", and "dismissed" -} - -// Utils - -export const exec = (db: Database.Database | null, sql: string, params: Array = [], cmd: 'run' | 'all' | 'get' = 'run') => { - if (!db) { - throw new Error("Database not initialized"); - } - - // Convert Date objects to ISO strings - const paramsConverted: Array = []; - for (const param of params) { - if (param instanceof Date) { - paramsConverted.push(param.toISOString()); - } else { - paramsConverted.push(param); - } - } - - // Execute the query - log.debug("Executing SQL:", sql, "Params:", paramsConverted); - try { - const stmt = db.prepare(sql); - const ret = stmt[cmd](...paramsConverted); - return ret - } catch (error) { - const exception = JSON.parse(packageExceptionForReport(error as Error)) - throw new Error(JSON.stringify({ - exception: exception, - sql: sql, - params: paramsConverted - })); - } -} - -// Config - -export const getConfig = (key: string, db: Database.Database | null = null): string | null => { - if (!db) { - db = getMainDatabase(); - } - const row: ConfigRow | undefined = exec(db, 'SELECT value FROM config WHERE key = ?', [key], 'get') as ConfigRow | undefined; - return row ? row.value : null; -} - -export const setConfig = (key: string, value: string, db: Database.Database | null = null) => { - if (!db) { - db = getMainDatabase(); - } - exec(db, 'INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]); -} - -// Error reports - -export const getErrorReport = (id: number): ErrorReport | null => { - const row: ErrorReportRow | undefined = exec(getMainDatabase(), 'SELECT * FROM errorReport WHERE id = ?', [id], 'get') as ErrorReportRow | undefined; - if (!row) { - return null; - } - return { - id: row.id, - createdAt: row.createdAt, - accountID: row.accountID, - appVersion: row.appVersion, - clientPlatform: row.clientPlatform, - accountType: row.accountType, - errorReportType: row.errorReportType, - errorReportData: row.errorReportData, - accountUsername: row.accountUsername, - screenshotDataURI: row.screenshotDataURI, - sensitiveContextData: row.sensitiveContextData, - status: row.status - } -} - -export const getNewErrorReports = (accountID: number): ErrorReport[] => { - const rows: ErrorReportRow[] = exec(getMainDatabase(), 'SELECT * FROM errorReport WHERE accountID = ? AND status = ?', [accountID, 'new'], 'all') as ErrorReportRow[]; - const errorReports: ErrorReport[] = []; - for (const row of rows) { - errorReports.push({ - id: row.id, - createdAt: row.createdAt, - accountID: row.accountID, - appVersion: row.appVersion, - clientPlatform: row.clientPlatform, - accountType: row.accountType, - errorReportType: row.errorReportType, - errorReportData: row.errorReportData, - accountUsername: row.accountUsername, - screenshotDataURI: row.screenshotDataURI, - sensitiveContextData: row.sensitiveContextData, - status: row.status - }); - } - return errorReports; -} - -export const createErrorReport = (accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null) => { - const info: Sqlite3Info = exec(getMainDatabase(), ` - INSERT INTO errorReport ( - accountID, - appVersion, - clientPlatform, - accountType, - errorReportType, - errorReportData, - accountUsername, - screenshotDataURI, - sensitiveContextData - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ - accountID, - app.getVersion(), - os.platform(), - accountType, - errorReportType, - errorReportData, - accountUsername, - screenshotDataURI, - sensitiveContextData - ]) as Sqlite3Info; - const report = getErrorReport(info.lastInsertRowid); - if (!report) { - throw new Error("Failed to create error report"); - } -} - -export const updateErrorReportSubmitted = (id: number) => { - exec(getMainDatabase(), 'DELETE FROM errorReport WHERE id = ?', [id]); -} - -export const dismissNewErrorReports = (accountID: number) => { - exec(getMainDatabase(), 'DELETE FROM errorReport WHERE accountID = ? AND status = ?', [accountID, 'new']); -} - -export const dismissAllNewErrorReports = () => { - exec(getMainDatabase(), 'DELETE FROM errorReport WHERE status = ?', ['new']); -} - -// X accounts - -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 { - 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 - }; -} - -export const getXAccounts = (): XAccount[] => { - const rows: XAccountRow[] = exec(getMainDatabase(), 'SELECT * FROM xAccount', [], 'all') as XAccountRow[]; - - 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 - }); - } - return accounts; -} - -export const createXAccount = (): XAccount => { - const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO xAccount DEFAULT VALUES') as Sqlite3Info; - const account = getXAccount(info.lastInsertRowid); - if (!account) { - throw new Error("Failed to create account"); - } - return account; -} - -// Update the account based on account.id -export const saveXAccount = (account: XAccount) => { - exec(getMainDatabase(), ` - UPDATE xAccount - SET - updatedAt = CURRENT_TIMESTAMP, - accessedAt = CURRENT_TIMESTAMP, - username = ?, - profileImageDataURI = ?, - importFromArchive = ?, - saveMyData = ?, - deleteMyData = ?, - archiveMyData = ?, - archiveTweets = ?, - archiveTweetsHTML = ?, - archiveLikes = ?, - archiveBookmarks = ?, - archiveDMs = ?, - deleteTweets = ?, - deleteTweetsDaysOld = ?, - deleteTweetsDaysOldEnabled = ?, - deleteTweetsLikesThresholdEnabled = ?, - deleteTweetsLikesThreshold = ?, - deleteTweetsRetweetsThresholdEnabled = ?, - deleteTweetsRetweetsThreshold = ?, - deleteRetweets = ?, - deleteRetweetsDaysOldEnabled = ?, - deleteRetweetsDaysOld = ?, - deleteLikes = ?, - deleteBookmarks = ?, - deleteDMs = ?, - unfollowEveryone = ?, - followingCount = ?, - followersCount = ?, - tweetsCount = ?, - likesCount = ? - WHERE id = ? - `, [ - account.username, - account.profileImageDataURI, - account.importFromArchive ? 1 : 0, - account.saveMyData ? 1 : 0, - account.deleteMyData ? 1 : 0, - account.archiveMyData ? 1 : 0, - account.archiveTweets ? 1 : 0, - account.archiveTweetsHTML ? 1 : 0, - account.archiveLikes ? 1 : 0, - account.archiveBookmarks ? 1 : 0, - account.archiveDMs ? 1 : 0, - account.deleteTweets ? 1 : 0, - account.deleteTweetsDaysOld, - account.deleteTweetsDaysOldEnabled ? 1 : 0, - account.deleteTweetsLikesThresholdEnabled ? 1 : 0, - account.deleteTweetsLikesThreshold, - account.deleteTweetsRetweetsThresholdEnabled ? 1 : 0, - account.deleteTweetsRetweetsThreshold, - account.deleteRetweets ? 1 : 0, - account.deleteRetweetsDaysOldEnabled ? 1 : 0, - account.deleteRetweetsDaysOld, - account.deleteLikes ? 1 : 0, - account.deleteBookmarks ? 1 : 0, - account.deleteDMs ? 1 : 0, - account.unfollowEveryone ? 1 : 0, - account.followingCount, - account.followersCount, - account.tweetsCount, - account.likesCount, - account.id - ]); -} - -// Accounts, which contain all others - -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; - } - - let xAccount: XAccount | null = null; - switch (row.type) { - case "X": - if (row.xAccountId) { - xAccount = getXAccount(row.xAccountId); - } - break; - } - - return { - id: row.id, - type: row.type, - sortOrder: row.sortOrder, - xAccount: xAccount, - uuid: row.uuid - }; -} - -export async function getAccountUsername(account: Account): Promise { - if (account.type == "X" && account.xAccount) { - return account.xAccount?.username; - } - - return null; -} - -export const getAccounts = (): Account[] => { - const rows: AccountRow[] = exec(getMainDatabase(), 'SELECT * FROM account', [], 'all') as AccountRow[]; - - const accounts: Account[] = []; - for (const row of rows) { - let xAccount: XAccount | null = null; - switch (row.type) { - case "X": - if (row.xAccountId) { - xAccount = getXAccount(row.xAccountId); - } - break; - } - - accounts.push({ - id: row.id, - type: row.type, - sortOrder: row.sortOrder, - xAccount: xAccount, - uuid: row.uuid - }); - } - return accounts; -} - -export const createAccount = (): Account => { - // Figure out the sortOrder for the new account - const row: { maxSortOrder: number } = exec(getMainDatabase(), 'SELECT MAX(sortOrder) as maxSortOrder FROM account', [], 'get') as { maxSortOrder: number }; - const sortOrder = row.maxSortOrder ? row.maxSortOrder + 1 : 0; - - // Insert it - const accountUUID = crypto.randomUUID(); - const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO account (sortOrder, uuid) VALUES (?, ?)', [sortOrder, accountUUID]) as Sqlite3Info; - - // Return it - const account = getAccount(info.lastInsertRowid); - if (!account) { - throw new Error("Failed to create account"); - } - return account; -} - -// Set account.type to type, create a new account of that type (right now, just xAccount), and return the account -export const selectAccountType = (accountID: number, type: string): Account => { - // Get the account - const account = getAccount(accountID); - if (!account) { - throw new Error("Account not found"); - } - if (account.type != "unknown") { - throw new Error("Account already has a type"); - } - - // Create the new account type - switch (type) { - case "X": - account.xAccount = createXAccount(); - break; - default: - throw new Error("Unknown account type"); - } - - // Update the account - exec(getMainDatabase(), ` - UPDATE account - SET - type = ?, - xAccountId = ? - WHERE id = ? - `, [ - type, - account.xAccount.id, - account.id - ]); - - account.type = type; - return account; -} - -// Update the account based on account.id -export const saveAccount = (account: Account) => { - if (account.xAccount) { - saveXAccount(account.xAccount); - } - - exec(getMainDatabase(), ` - UPDATE account - SET - type = ?, - sortOrder = ? - WHERE id = ? - `, [ - account.type, - account.sortOrder, - account.id - ]); -} - -export const deleteAccount = (accountID: number) => { - // Get the account - const account = getAccount(accountID); - if (!account) { - throw new Error("Account not found"); - } - - // Delete the account type - switch (account.type) { - case "X": - if (account.xAccount) { - exec(getMainDatabase(), 'DELETE FROM xAccount WHERE id = ?', [account.xAccount.id]); - } - break; - } - - // Delete the account - exec(getMainDatabase(), 'DELETE FROM account WHERE id = ?', [accountID]); -} - -export const defineIPCDatabase = () => { - ipcMain.handle('database:getConfig', async (_, key) => { - try { - return getConfig(key); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:setConfig', async (_, key, value) => { - try { - setConfig(key, value); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:getErrorReport', async (_, id): Promise => { - try { - return getErrorReport(id); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:getNewErrorReports', async (_, accountID: number): Promise => { - try { - return getNewErrorReports(accountID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:createErrorReport', async (_, accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null): Promise => { - try { - createErrorReport(accountID, accountType, errorReportType, errorReportData, accountUsername, screenshotDataURI, sensitiveContextData); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:updateErrorReportSubmitted', async (_, id): Promise => { - try { - updateErrorReportSubmitted(id); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:dismissNewErrorReports', async (_, accountID: number): Promise => { - try { - dismissNewErrorReports(accountID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:getAccount', async (_, accountID): Promise => { - try { - return getAccount(accountID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:getAccounts', async (_): Promise => { - try { - return getAccounts(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:createAccount', async (_) => { - try { - return createAccount(); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:selectAccountType', async (_, accountID, type) => { - try { - return selectAccountType(accountID, type); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:saveAccount', async (_, accountJson) => { - try { - const account = JSON.parse(accountJson); - return saveAccount(account); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); - - ipcMain.handle('database:deleteAccount', async (_, accountID) => { - try { - const ses = session.fromPartition(`persist:account-${accountID}`); - await ses.closeAllConnections(); - await ses.clearStorageData(); - deleteAccount(accountID); - } catch (error) { - throw new Error(packageExceptionForReport(error as Error)); - } - }); -} \ No newline at end of file diff --git a/src/database/account.ts b/src/database/account.ts new file mode 100644 index 00000000..d5eb5b20 --- /dev/null +++ b/src/database/account.ts @@ -0,0 +1,259 @@ +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 { packageExceptionForReport } from "../util" + +// Types + +interface AccountRow { + id: number; + type: string; + sortOrder: number; + xAccountId: number | null; + blueskyAccountID: 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; + } + + 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; + } + + return { + id: row.id, + type: row.type, + sortOrder: row.sortOrder, + xAccount: xAccount, + blueskyAccount: blueskyAccount, + uuid: row.uuid + }; +} + +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; + } + + return null; +} + +export const getAccounts = (): Account[] => { + const rows: AccountRow[] = exec(getMainDatabase(), 'SELECT * FROM account', [], 'all') as AccountRow[]; + + 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 + }); + } + return accounts; +} + +export const createAccount = (): Account => { + // Figure out the sortOrder for the new account + const row: { maxSortOrder: number } = exec(getMainDatabase(), 'SELECT MAX(sortOrder) as maxSortOrder FROM account', [], 'get') as { maxSortOrder: number }; + const sortOrder = row.maxSortOrder ? row.maxSortOrder + 1 : 0; + + // Insert it + const accountUUID = crypto.randomUUID(); + const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO account (sortOrder, uuid) VALUES (?, ?)', [sortOrder, accountUUID]) as Sqlite3Info; + + // Return it + const account = getAccount(info.lastInsertRowid); + if (!account) { + throw new Error("Failed to create account"); + } + return account; +} + +// Set account.type to type, create a new account of that type (right now, just xAccount), and return the account +export const selectAccountType = (accountID: number, type: string): Account => { + // Get the account + const account = getAccount(accountID); + if (!account) { + throw new Error("Account not found"); + } + if (account.type != "unknown") { + throw new Error("Account already has a type"); + } + + // Create the new account type + switch (type) { + case "X": + account.xAccount = createXAccount(); + break; + case "Bluesky": + account.blueskyAccount = createBlueskyAccount(); + break; + default: + throw new Error("Unknown account type"); + } + + const xAccountId = account.xAccount ? account.xAccount.id : null; + const blueskyAccountID = account.blueskyAccount ? account.blueskyAccount.id : null; + + // Update the account + exec(getMainDatabase(), ` + UPDATE account + SET + type = ?, + xAccountId = ?, + blueskyAccountID = ? + WHERE id = ? + `, [ + type, + xAccountId, + blueskyAccountID, + account.id + ]); + + account.type = type; + return account; +} + +// Update the account based on account.id +export const saveAccount = (account: Account) => { + if (account.xAccount) { + saveXAccount(account.xAccount); + } + else if (account.blueskyAccount) { + saveBlueskyAccount(account.blueskyAccount); + } + + exec(getMainDatabase(), ` + UPDATE account + SET + type = ?, + sortOrder = ? + WHERE id = ? + `, [ + account.type, + account.sortOrder, + account.id + ]); +} + +export const deleteAccount = (accountID: number) => { + // Get the account + const account = getAccount(accountID); + if (!account) { + throw new Error("Account not found"); + } + + // Delete the account type + switch (account.type) { + case "X": + if (account.xAccount) { + exec(getMainDatabase(), 'DELETE FROM xAccount WHERE id = ?', [account.xAccount.id]); + } + break; + case "Bluesky": + if (account.blueskyAccount) { + exec(getMainDatabase(), 'DELETE FROM blueskyAccount WHERE id = ?', [account.blueskyAccount.id]); + } + break; + } + + // Delete the account + exec(getMainDatabase(), 'DELETE FROM account WHERE id = ?', [accountID]); +} + +// IPC + +export const defineIPCDatabaseAccount = () => { + ipcMain.handle('database:getAccount', async (_, accountID): Promise => { + try { + return getAccount(accountID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:getAccounts', async (_): Promise => { + try { + return getAccounts(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:createAccount', async (_) => { + try { + return createAccount(); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:selectAccountType', async (_, accountID, type) => { + try { + return selectAccountType(accountID, type); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:saveAccount', async (_, accountJson) => { + try { + const account = JSON.parse(accountJson); + return saveAccount(account); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:deleteAccount', async (_, accountID) => { + try { + const ses = session.fromPartition(`persist:account-${accountID}`); + await ses.closeAllConnections(); + await ses.clearStorageData(); + deleteAccount(accountID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); +} \ No newline at end of file diff --git a/src/database/bluesky_account.ts b/src/database/bluesky_account.ts new file mode 100644 index 00000000..fff67ff1 --- /dev/null +++ b/src/database/bluesky_account.ts @@ -0,0 +1,186 @@ +import { exec, getMainDatabase, Sqlite3Info } from './common'; +import { BlueskyAccount } from '../shared_types' + +// Types + +export interface BlueskyAccountRow { + id: number; + createdAt: string; + updatedAt: string; + accessedAt: string; + username: string; + profileImageDataURI: string; + saveMyData: boolean; + deleteMyData: boolean; + archivePosts: boolean; + archivePostsHTML: boolean; + archiveLikes: boolean; + deletePosts: boolean; + deletePostsDaysOldEnabled: boolean; + deletePostsDaysOld: number; + deletePostsLikesThresholdEnabled: boolean; + deletePostsLikesThreshold: number; + deletePostsRepostsThresholdEnabled: boolean; + deletePostsRepostsThreshold: number; + deleteReposts: boolean; + deleteRepostsDaysOldEnabled: boolean; + deleteRepostsDaysOld: number; + deleteLikes: boolean; + deleteLikesDaysOldEnabled: boolean; + deleteLikesDaysOld: number; + followingCount: number; + followersCount: number; + postsCount: number; + likesCount: number; +} + +// Functions + +// Get a single Bluesky account by ID +export const getBlueskyAccount = (id: number): BlueskyAccount | null => { + const row: BlueskyAccountRow | undefined = exec(getMainDatabase(), 'SELECT * FROM blueskyAccount WHERE id = ?', [id], 'get') as BlueskyAccountRow | undefined; + if (!row) { + return null; + } + return { + id: row.id, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + accessedAt: new Date(row.accessedAt), + username: row.username, + profileImageDataURI: row.profileImageDataURI, + saveMyData: !!row.saveMyData, + deleteMyData: !!row.deleteMyData, + archivePosts: !!row.archivePosts, + archivePostsHTML: !!row.archivePostsHTML, + archiveLikes: !!row.archiveLikes, + deletePosts: !!row.deletePosts, + deletePostsDaysOldEnabled: !!row.deletePostsDaysOldEnabled, + deletePostsDaysOld: row.deletePostsDaysOld, + deletePostsLikesThresholdEnabled: !!row.deletePostsLikesThresholdEnabled, + deletePostsLikesThreshold: row.deletePostsLikesThreshold, + deletePostsRepostsThresholdEnabled: !!row.deletePostsRepostsThresholdEnabled, + deletePostsRepostsThreshold: row.deletePostsRepostsThreshold, + deleteReposts: !!row.deleteReposts, + deleteRepostsDaysOldEnabled: !!row.deleteRepostsDaysOldEnabled, + deleteRepostsDaysOld: row.deleteRepostsDaysOld, + deleteLikes: !!row.deleteLikes, + deleteLikesDaysOldEnabled: !!row.deleteLikesDaysOldEnabled, + deleteLikesDaysOld: row.deleteLikesDaysOld, + followingCount: row.followingCount, + followersCount: row.followersCount, + postsCount: row.postsCount, + likesCount: row.likesCount + }; +} + +// Get all Bluesky accounts +export const getBlueskyAccounts = (): BlueskyAccount[] => { + const rows: BlueskyAccountRow[] = exec(getMainDatabase(), 'SELECT * FROM blueskyAccount', [], 'all') as BlueskyAccountRow[]; + + const accounts: BlueskyAccount[] = []; + 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, + saveMyData: !!row.saveMyData, + deleteMyData: !!row.deleteMyData, + archivePosts: !!row.archivePosts, + archivePostsHTML: !!row.archivePostsHTML, + archiveLikes: !!row.archiveLikes, + deletePosts: !!row.deletePosts, + deletePostsDaysOldEnabled: !!row.deletePostsDaysOldEnabled, + deletePostsDaysOld: row.deletePostsDaysOld, + deletePostsLikesThresholdEnabled: !!row.deletePostsLikesThresholdEnabled, + deletePostsLikesThreshold: row.deletePostsLikesThreshold, + deletePostsRepostsThresholdEnabled: !!row.deletePostsRepostsThresholdEnabled, + deletePostsRepostsThreshold: row.deletePostsRepostsThreshold, + deleteReposts: !!row.deleteReposts, + deleteRepostsDaysOldEnabled: !!row.deleteRepostsDaysOldEnabled, + deleteRepostsDaysOld: row.deleteRepostsDaysOld, + deleteLikes: !!row.deleteLikes, + deleteLikesDaysOldEnabled: !!row.deleteLikesDaysOldEnabled, + deleteLikesDaysOld: row.deleteLikesDaysOld, + followingCount: row.followingCount, + followersCount: row.followersCount, + postsCount: row.postsCount, + likesCount: row.likesCount + }); + } + return accounts; +} + +// Create a new Bluesky account +export const createBlueskyAccount = (): BlueskyAccount => { + const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO blueskyAccount DEFAULT VALUES') as Sqlite3Info; + const account = getBlueskyAccount(info.lastInsertRowid); + if (!account) { + throw new Error("Failed to create account"); + } + return account; +} + +// Update the Bluesky account based on account.id +export const saveBlueskyAccount = (account: BlueskyAccount) => { + exec(getMainDatabase(), ` + UPDATE blueskyAccount + SET + updatedAt = CURRENT_TIMESTAMP, + accessedAt = CURRENT_TIMESTAMP, + username = ?, + profileImageDataURI = ?, + saveMyData = ?, + deleteMyData = ?, + archivePosts = ?, + archivePostsHTML = ?, + archiveLikes = ?, + deletePosts = ?, + deletePostsDaysOld = ?, + deletePostsDaysOldEnabled = ?, + deletePostsLikesThresholdEnabled = ?, + deletePostsLikesThreshold = ?, + deletePostsRepostsThresholdEnabled = ?, + deletePostsRepostsThreshold = ?, + deleteReposts = ?, + deleteRepostsDaysOldEnabled = ?, + deleteRepostsDaysOld = ?, + deleteLikes = ?, + deleteLikesDaysOldEnabled = ?, + deleteLikesDaysOld = ?, + followingCount = ?, + followersCount = ?, + postsCount = ?, + likesCount = ? + WHERE id = ? + `, [ + account.username, + account.profileImageDataURI, + account.saveMyData ? 1 : 0, + account.deleteMyData ? 1 : 0, + account.archivePosts ? 1 : 0, + account.archivePostsHTML ? 1 : 0, + account.archiveLikes ? 1 : 0, + account.deletePosts ? 1 : 0, + account.deletePostsDaysOld, + account.deletePostsDaysOldEnabled ? 1 : 0, + account.deletePostsLikesThresholdEnabled ? 1 : 0, + account.deletePostsLikesThreshold, + account.deletePostsRepostsThresholdEnabled ? 1 : 0, + account.deletePostsRepostsThreshold, + account.deleteReposts ? 1 : 0, + account.deleteRepostsDaysOldEnabled ? 1 : 0, + account.deleteRepostsDaysOld, + account.deleteLikes ? 1 : 0, + account.deleteLikesDaysOldEnabled ? 1 : 0, + account.deleteLikesDaysOld, + account.followingCount, + account.followersCount, + account.postsCount, + account.likesCount, + account.id + ]); +} diff --git a/src/database/common.ts b/src/database/common.ts new file mode 100644 index 00000000..361fc226 --- /dev/null +++ b/src/database/common.ts @@ -0,0 +1,105 @@ +import path from "path" + +import log from 'electron-log/main'; +import Database from 'better-sqlite3' + +import { getSettingsPath, packageExceptionForReport } from "../util" + +// Types + +export interface Sqlite3Info { + lastInsertRowid: number; + changes: number; +} + +export interface Sqlite3Count { + count: number; +} + +// Migrations + +export type Migration = { + name: string; + sql: string[]; +}; + +export const runMigrations = (db: Database.Database, migrations: Migration[]) => { + // Create a migrations table if necessary + const migrationsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").get(); + if (!migrationsTable) { + // Create the migrations table + log.debug("Creating migrations table"); + db.prepare(`CREATE TABLE migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + runAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)`).run(); + } + + // Apply the migrations in order + for (const migration of migrations) { + const migrationRecord = db.prepare("SELECT * FROM migrations WHERE name = ?").get(migration.name); + if (!migrationRecord) { + log.info(`Running migration: ${migration.name}`); + for (const sql of migration.sql) { + db.exec(sql); + } + db.prepare("INSERT INTO migrations (name) VALUES (?)").run(migration.name); + } + } +} + +// Main database + +let mainDatabase: Database.Database | null = null; + +export const getMainDatabase = () => { + if (mainDatabase) { + return mainDatabase; + } + + const dbPath = path.join(getSettingsPath(), 'db.sqlite'); + mainDatabase = new Database(dbPath, {}); + mainDatabase.pragma('journal_mode = WAL'); + return mainDatabase; +} + +export const closeMainDatabase = () => { + if (mainDatabase) { + mainDatabase.close(); + mainDatabase = null; + } +} + +// Utils + +export const exec = (db: Database.Database | null, sql: string, params: Array = [], cmd: 'run' | 'all' | 'get' = 'run') => { + if (!db) { + throw new Error("Database not initialized"); + } + + // Convert Date objects to ISO strings + const paramsConverted: Array = []; + for (const param of params) { + if (param instanceof Date) { + paramsConverted.push(param.toISOString()); + } else { + paramsConverted.push(param); + } + } + + // Execute the query + log.debug("Executing SQL:", sql, "Params:", paramsConverted); + try { + const stmt = db.prepare(sql); + const ret = stmt[cmd](...paramsConverted); + return ret + } catch (error) { + const exception = JSON.parse(packageExceptionForReport(error as Error)) + throw new Error(JSON.stringify({ + exception: exception, + sql: sql, + params: paramsConverted + })); + } +} diff --git a/src/database/config.ts b/src/database/config.ts new file mode 100644 index 00000000..22150854 --- /dev/null +++ b/src/database/config.ts @@ -0,0 +1,49 @@ +import Database from 'better-sqlite3' +import { ipcMain } from 'electron'; + +import { exec, getMainDatabase } from './common'; +import { packageExceptionForReport } from "../util" + +// Types + +interface ConfigRow { + key: string; + value: string; +} + +// Functions + +export const getConfig = (key: string, db: Database.Database | null = null): string | null => { + if (!db) { + db = getMainDatabase(); + } + const row: ConfigRow | undefined = exec(db, 'SELECT value FROM config WHERE key = ?', [key], 'get') as ConfigRow | undefined; + return row ? row.value : null; +} + +export const setConfig = (key: string, value: string, db: Database.Database | null = null) => { + if (!db) { + db = getMainDatabase(); + } + exec(db, 'INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]); +} + +// IPC + +export const defineIPCDatabaseConfig = () => { + ipcMain.handle('database:getConfig', async (_, key) => { + try { + return getConfig(key); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:setConfig', async (_, key, value) => { + try { + setConfig(key, value); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); +} \ No newline at end of file diff --git a/src/database/error_report.ts b/src/database/error_report.ts new file mode 100644 index 00000000..3cae53e1 --- /dev/null +++ b/src/database/error_report.ts @@ -0,0 +1,156 @@ +import os from 'os'; + +import { app, ipcMain } from 'electron'; + +import { exec, getMainDatabase, Sqlite3Info } from './common'; +import { ErrorReport } from '../shared_types' +import { packageExceptionForReport } from "../util" + +// Types + +export interface ErrorReportRow { + id: number; + createdAt: string; + accountID: number; + appVersion: string; + clientPlatform: string; + accountType: string; + errorReportType: string; + errorReportData: string; + accountUsername: string; + screenshotDataURI: string; + sensitiveContextData: string; + status: string; // "new", "submitted", and "dismissed" +} + +// Functions + +export const getErrorReport = (id: number): ErrorReport | null => { + const row: ErrorReportRow | undefined = exec(getMainDatabase(), 'SELECT * FROM errorReport WHERE id = ?', [id], 'get') as ErrorReportRow | undefined; + if (!row) { + return null; + } + return { + id: row.id, + createdAt: row.createdAt, + accountID: row.accountID, + appVersion: row.appVersion, + clientPlatform: row.clientPlatform, + accountType: row.accountType, + errorReportType: row.errorReportType, + errorReportData: row.errorReportData, + accountUsername: row.accountUsername, + screenshotDataURI: row.screenshotDataURI, + sensitiveContextData: row.sensitiveContextData, + status: row.status + } +} + +export const getNewErrorReports = (accountID: number): ErrorReport[] => { + const rows: ErrorReportRow[] = exec(getMainDatabase(), 'SELECT * FROM errorReport WHERE accountID = ? AND status = ?', [accountID, 'new'], 'all') as ErrorReportRow[]; + const errorReports: ErrorReport[] = []; + for (const row of rows) { + errorReports.push({ + id: row.id, + createdAt: row.createdAt, + accountID: row.accountID, + appVersion: row.appVersion, + clientPlatform: row.clientPlatform, + accountType: row.accountType, + errorReportType: row.errorReportType, + errorReportData: row.errorReportData, + accountUsername: row.accountUsername, + screenshotDataURI: row.screenshotDataURI, + sensitiveContextData: row.sensitiveContextData, + status: row.status + }); + } + return errorReports; +} + +export const createErrorReport = (accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null) => { + const info: Sqlite3Info = exec(getMainDatabase(), ` + INSERT INTO errorReport ( + accountID, + appVersion, + clientPlatform, + accountType, + errorReportType, + errorReportData, + accountUsername, + screenshotDataURI, + sensitiveContextData + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + accountID, + app.getVersion(), + os.platform(), + accountType, + errorReportType, + errorReportData, + accountUsername, + screenshotDataURI, + sensitiveContextData + ]) as Sqlite3Info; + const report = getErrorReport(info.lastInsertRowid); + if (!report) { + throw new Error("Failed to create error report"); + } +} + +export const updateErrorReportSubmitted = (id: number) => { + exec(getMainDatabase(), 'DELETE FROM errorReport WHERE id = ?', [id]); +} + +export const dismissNewErrorReports = (accountID: number) => { + exec(getMainDatabase(), 'DELETE FROM errorReport WHERE accountID = ? AND status = ?', [accountID, 'new']); +} + +export const dismissAllNewErrorReports = () => { + exec(getMainDatabase(), 'DELETE FROM errorReport WHERE status = ?', ['new']); +} + +// IPC + +export const defineIPCDatabaseErrorReport = () => { + ipcMain.handle('database:getErrorReport', async (_, id): Promise => { + try { + return getErrorReport(id); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:getNewErrorReports', async (_, accountID: number): Promise => { + try { + return getNewErrorReports(accountID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:createErrorReport', async (_, accountID: number, accountType: string, errorReportType: string, errorReportData: string, accountUsername: string | null, screenshotDataURI: string | null, sensitiveContextData: string | null): Promise => { + try { + createErrorReport(accountID, accountType, errorReportType, errorReportData, accountUsername, screenshotDataURI, sensitiveContextData); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:updateErrorReportSubmitted', async (_, id): Promise => { + try { + updateErrorReportSubmitted(id); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + + ipcMain.handle('database:dismissNewErrorReports', async (_, accountID: number): Promise => { + try { + dismissNewErrorReports(accountID); + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); +} \ No newline at end of file diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 00000000..d74bec2c --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,21 @@ +export * from './common'; +export * from './migrations'; + +export * from './config'; +export * from './error_report'; + +export * from './account'; +export * from './x_account'; +export * from './bluesky_account'; + +// IPC + +import { defineIPCDatabaseConfig } from './config'; +import { defineIPCDatabaseErrorReport } from './error_report'; +import { defineIPCDatabaseAccount } from './account'; + +export const defineIPCDatabase = () => { + defineIPCDatabaseConfig(); + defineIPCDatabaseErrorReport(); + defineIPCDatabaseAccount(); +} diff --git a/src/database/migrations.ts b/src/database/migrations.ts new file mode 100644 index 00000000..3abe8afa --- /dev/null +++ b/src/database/migrations.ts @@ -0,0 +1,147 @@ +import { runMigrations, getMainDatabase } from "./common"; + +export const runMainMigrations = () => { + runMigrations(getMainDatabase(), [ + // Create the tables + { + name: "initial", + sql: [ + `CREATE TABLE config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL +);`, `CREATE TABLE account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL DEFAULT 'unknown', + sortOrder INTEGER NOT NULL DEFAULT 0, + xAccountId INTEGER DEFAULT NULL, + uuid TEXT NOT NULL +);`, `CREATE TABLE xAccount ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + accessedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + username TEXT, + profileImageDataURI TEXT, + saveMyData BOOLEAN DEFAULT 1, + deleteMyData BOOLEAN DEFAULT 0, + archiveTweets BOOLEAN DEFAULT 1, + archiveTweetsHTML BOOLEAN DEFAULT 0, + archiveLikes BOOLEAN DEFAULT 1, + archiveDMs BOOLEAN DEFAULT 1, + deleteTweets BOOLEAN DEFAULT 1, + deleteTweetsDaysOld INTEGER DEFAULT 0, + deleteTweetsLikesThresholdEnabled BOOLEAN DEFAULT 0, + deleteTweetsLikesThreshold INTEGER DEFAULT 20, + deleteTweetsRetweetsThresholdEnabled BOOLEAN DEFAULT 0, + deleteTweetsRetweetsThreshold INTEGER DEFAULT 20, + deleteRetweets BOOLEAN DEFAULT 1, + deleteRetweetsDaysOld INTEGER DEFAULT 0, + deleteLikes BOOLEAN DEFAULT 0, + deleteLikesDaysOld INTEGER DEFAULT 0, + deleteDMs BOOLEAN DEFAULT 0 +);`, + ] + }, + // Add importFromArchive, followingCount, follwersCount, tweetsCount, likesCount to xAccount + { + name: "add importFromArchive, followingCount, follwersCount, tweetsCount, likesCount to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN importFromArchive BOOLEAN DEFAULT 1;`, + `ALTER TABLE xAccount ADD COLUMN followingCount INTEGER DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN followersCount INTEGER DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN tweetsCount INTEGER DEFAULT -1;`, + `ALTER TABLE xAccount ADD COLUMN likesCount INTEGER DEFAULT -1;`, + ] + }, + // Add unfollowEveryone to xAccount + { + name: "add unfollowEveryone to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN unfollowEveryone BOOLEAN DEFAULT 1;`, + ] + }, + // Add deleteTweetsDaysOldEnabled, deleteRetweetsDaysOldEnabled, deleteLikesDaysOldEnabled to xAccount + { + name: "add deleteTweetsDaysOldEnabled, deleteRetweetsDaysOldEnabled, deleteLikesDaysOldEnabled to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN deleteTweetsDaysOldEnabled BOOLEAN DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN deleteRetweetsDaysOldEnabled BOOLEAN DEFAULT 0;`, + `ALTER TABLE xAccount ADD COLUMN deleteLikesDaysOldEnabled BOOLEAN DEFAULT 0;`, + ] + }, + // Add errorReport table. Status can be "new", "submitted", and "dismissed" + { + name: "add errorReport table", + sql: [ + `CREATE TABLE errorReport ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + accountID INTEGER DEFAULT NULL, + appVersion TEXT DEFAULT NULL, + clientPlatform TEXT DEFAULT NULL, + accountType TEXT DEFAULT NULL, + errorReportType TEXT NOT NULL, + errorReportData TEXT DEFAULT NULL, + accountUsername TEXT DEFAULT NULL, + screenshotDataURI TEXT DEFAULT NULL, + sensitiveContextData TEXT DEFAULT NULL, + status TEXT DEFAULT 'new' +);`, + ] + }, + // Add archiveMyData to xAccount + { + name: "add archiveMyData to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN archiveMyData BOOLEAN DEFAULT 0;`, + ] + }, + // Add archiveBookmarks, deleteBookmarks to xAccount + { + name: "add archiveBookmarks, deleteBookmarks to xAccount", + sql: [ + `ALTER TABLE xAccount ADD COLUMN archiveBookmarks BOOLEAN DEFAULT 1;`, + `ALTER TABLE xAccount ADD COLUMN deleteBookmarks BOOLEAN DEFAULT 0;`, + ] + }, + // Add Bluesky table, and blueskyAccountID to account + { + name: "add Bluesky table, and blueskyAccountID to account", + sql: [ + `CREATE TABLE blueskyAccount ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + accessedAt DATETIME DEFAULT CURRENT_TIMESTAMP, + username TEXT, + profileImageDataURI TEXT, + saveMyData BOOLEAN DEFAULT 1, + deleteMyData BOOLEAN DEFAULT 0, + archivePosts BOOLEAN DEFAULT 1, + archivePostsHTML BOOLEAN DEFAULT 0, + archiveLikes BOOLEAN DEFAULT 1, + deletePosts BOOLEAN DEFAULT 1, + deletePostsDaysOldEnabled BOOLEAN DEFAULT 0, + deletePostsDaysOld INTEGER DEFAULT 0, + deletePostsLikesThresholdEnabled BOOLEAN DEFAULT 0, + deletePostsLikesThreshold INTEGER DEFAULT 20, + deletePostsRepostsThresholdEnabled BOOLEAN DEFAULT 0, + deletePostsRepostsThreshold INTEGER DEFAULT 20, + deleteReposts BOOLEAN DEFAULT 1, + deleteRepostsDaysOldEnabled BOOLEAN DEFAULT 0, + deleteRepostsDaysOld INTEGER DEFAULT 0, + deleteLikes BOOLEAN DEFAULT 0, + deleteLikesDaysOldEnabled BOOLEAN DEFAULT 0, + deleteLikesDaysOld INTEGER DEFAULT 0, + unfollowEveryone BOOLEAN DEFAULT 1, + followingCount INTEGER DEFAULT 0, + followersCount INTEGER DEFAULT 0, + postsCount INTEGER DEFAULT -1, + likesCount INTEGER DEFAULT -1 +);`, + `ALTER TABLE account ADD COLUMN blueskyAccountID INTEGER DEFAULT NULL;`, + ] + }, + ]); +} \ No newline at end of file diff --git a/src/database/x_account.ts b/src/database/x_account.ts new file mode 100644 index 00000000..4885bd87 --- /dev/null +++ b/src/database/x_account.ts @@ -0,0 +1,208 @@ +import { exec, getMainDatabase, Sqlite3Info } from './common'; +import { XAccount } from '../shared_types' + +// Types + +interface XAccountRow { + id: number; + createdAt: string; + updatedAt: string; + accessedAt: string; + username: string; + profileImageDataURI: string; + importFromArchive: boolean; + saveMyData: boolean; + deleteMyData: boolean; + archiveMyData: boolean; + archiveTweets: boolean; + archiveTweetsHTML: boolean; + archiveLikes: boolean; + archiveBookmarks: boolean; + archiveDMs: boolean; + deleteTweets: boolean; + deleteTweetsDaysOldEnabled: boolean; + deleteTweetsDaysOld: number; + deleteTweetsLikesThresholdEnabled: boolean; + deleteTweetsLikesThreshold: number; + deleteTweetsRetweetsThresholdEnabled: boolean; + deleteTweetsRetweetsThreshold: number; + deleteRetweets: boolean; + deleteRetweetsDaysOldEnabled: boolean; + deleteRetweetsDaysOld: number; + deleteLikes: boolean; + deleteBookmarks: number; + deleteDMs: boolean; + unfollowEveryone: boolean; + followingCount: number; + followersCount: number; + tweetsCount: number; + 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; + } + return { + 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 + }; +} + +export const getXAccounts = (): XAccount[] => { + const rows: XAccountRow[] = exec(getMainDatabase(), 'SELECT * FROM xAccount', [], 'all') as XAccountRow[]; + + 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 + }); + } + return accounts; +} + +export const createXAccount = (): XAccount => { + const info: Sqlite3Info = exec(getMainDatabase(), 'INSERT INTO xAccount DEFAULT VALUES') as Sqlite3Info; + const account = getXAccount(info.lastInsertRowid); + if (!account) { + throw new Error("Failed to create account"); + } + return account; +} + +// Update the account based on account.id +export const saveXAccount = (account: XAccount) => { + exec(getMainDatabase(), ` + UPDATE xAccount + SET + updatedAt = CURRENT_TIMESTAMP, + accessedAt = CURRENT_TIMESTAMP, + username = ?, + profileImageDataURI = ?, + importFromArchive = ?, + saveMyData = ?, + deleteMyData = ?, + archiveMyData = ?, + archiveTweets = ?, + archiveTweetsHTML = ?, + archiveLikes = ?, + archiveBookmarks = ?, + archiveDMs = ?, + deleteTweets = ?, + deleteTweetsDaysOld = ?, + deleteTweetsDaysOldEnabled = ?, + deleteTweetsLikesThresholdEnabled = ?, + deleteTweetsLikesThreshold = ?, + deleteTweetsRetweetsThresholdEnabled = ?, + deleteTweetsRetweetsThreshold = ?, + deleteRetweets = ?, + deleteRetweetsDaysOldEnabled = ?, + deleteRetweetsDaysOld = ?, + deleteLikes = ?, + deleteBookmarks = ?, + deleteDMs = ?, + unfollowEveryone = ?, + followingCount = ?, + followersCount = ?, + tweetsCount = ?, + likesCount = ? + WHERE id = ? + `, [ + account.username, + account.profileImageDataURI, + account.importFromArchive ? 1 : 0, + account.saveMyData ? 1 : 0, + account.deleteMyData ? 1 : 0, + account.archiveMyData ? 1 : 0, + account.archiveTweets ? 1 : 0, + account.archiveTweetsHTML ? 1 : 0, + account.archiveLikes ? 1 : 0, + account.archiveBookmarks ? 1 : 0, + account.archiveDMs ? 1 : 0, + account.deleteTweets ? 1 : 0, + account.deleteTweetsDaysOld, + account.deleteTweetsDaysOldEnabled ? 1 : 0, + account.deleteTweetsLikesThresholdEnabled ? 1 : 0, + account.deleteTweetsLikesThreshold, + account.deleteTweetsRetweetsThresholdEnabled ? 1 : 0, + account.deleteTweetsRetweetsThreshold, + account.deleteRetweets ? 1 : 0, + account.deleteRetweetsDaysOldEnabled ? 1 : 0, + account.deleteRetweetsDaysOld, + account.deleteLikes ? 1 : 0, + account.deleteBookmarks ? 1 : 0, + account.deleteDMs ? 1 : 0, + account.unfollowEveryone ? 1 : 0, + account.followingCount, + account.followersCount, + account.tweetsCount, + account.likesCount, + account.id + ]); +} diff --git a/src/renderer/src/test_util.ts b/src/renderer/src/test_util.ts index 3c6e56f5..6e9fc491 100644 --- a/src/renderer/src/test_util.ts +++ b/src/renderer/src/test_util.ts @@ -1,87 +1,91 @@ export const stubElectron = () => { return { checkForUpdates: cy.stub(), - getVersion: cy.stub(), - getMode: cy.stub(), - getPlatform: cy.stub(), - getAPIURL: cy.stub(), - getDashURL: cy.stub(), - trackEvent: cy.stub(), - shouldOpenDevtools: cy.stub(), + getVersion: cy.stub().resolves('0.0.1'), + getMode: cy.stub().resolves('prod'), + getPlatform: cy.stub().resolves('win32'), + getAPIURL: cy.stub().resolves('https://api.example.com'), + getDashURL: cy.stub().resolves('https://dash.example.com'), + trackEvent: cy.stub().resolves('tracked'), + shouldOpenDevtools: cy.stub().resolves(false), showMessage: cy.stub(), showError: cy.stub(), - showQuestion: cy.stub(), - showSelectFolderDialog: cy.stub(), + showQuestion: cy.stub().resolves(true), + showSelectZIPFileDialog: cy.stub().resolves(null), + showSelectFolderDialog: cy.stub().resolves(null), openURL: cy.stub(), loadFileInWebview: cy.stub(), - getAccountDataPath: cy.stub(), - startPowerSaveBlocker: cy.stub(), + getAccountDataPath: cy.stub().resolves(null), + startPowerSaveBlocker: cy.stub().resolves(1), stopPowerSaveBlocker: cy.stub(), deleteSettingsAndRestart: cy.stub(), database: { - getConfig: cy.stub(), + getConfig: cy.stub().resolves(null), setConfig: cy.stub(), - getErrorReport: cy.stub(), - getNewErrorReports: cy.stub(), - createErrorReport: cy.stub(), + getErrorReport: cy.stub().resolves(null), + getNewErrorReports: cy.stub().resolves([]), + createErrorReport: cy.stub().resolves(), updateErrorReportSubmitted: cy.stub(), dismissNewErrorReports: cy.stub(), - getAccount: cy.stub(), - getAccounts: cy.stub(), - createAccount: cy.stub(), - selectAccountType: cy.stub(), + getAccount: cy.stub().resolves(null), + getAccounts: cy.stub().resolves([]), + createAccount: cy.stub().resolves({ id: 1, type: 'X', sortOrder: 0, xAccount: null, blueskyAccount: null, uuid: 'uuid' }), + selectAccountType: cy.stub().resolves({ id: 1, type: 'X', sortOrder: 0, xAccount: null, blueskyAccount: null, uuid: 'uuid' }), saveAccount: cy.stub(), deleteAccount: cy.stub(), }, archive: { - isPageAlreadySaved: cy.stub(), - savePage: cy.stub(), + isPageAlreadySaved: cy.stub().resolves(false), + savePage: cy.stub().resolves(false), }, X: { - resetProgress: cy.stub(), - createJobs: cy.stub(), - getLastFinishedJob: cy.stub(), + resetProgress: cy.stub().resolves({}), + createJobs: cy.stub().resolves([]), + getLastFinishedJob: cy.stub().resolves(null), updateJob: cy.stub(), indexStart: cy.stub(), indexStop: cy.stub(), - indexParseAllJSON: cy.stub(), - indexParseTweets: cy.stub(), - indexParseConversations: cy.stub(), - indexIsThereMore: cy.stub(), - resetThereIsMore: cy.stub(), - indexMessagesStart: cy.stub(), - indexParseMessages: cy.stub(), - indexConversationFinished: cy.stub(), - archiveTweetsStart: cy.stub(), - archiveTweetsOutputPath: cy.stub(), - archiveTweet: cy.stub(), - archiveTweetCheckDate: cy.stub(), - archiveBuild: cy.stub(), + indexParseAllJSON: cy.stub().resolves({}), + indexParseTweets: cy.stub().resolves({}), + indexParseConversations: cy.stub().resolves({}), + indexIsThereMore: cy.stub().resolves(false), + resetThereIsMore: cy.stub().resolves(), + indexMessagesStart: cy.stub().resolves({}), + indexParseMessages: cy.stub().resolves({}), + indexConversationFinished: cy.stub().resolves(), + archiveTweetsStart: cy.stub().resolves({}), + archiveTweetsOutputPath: cy.stub().resolves(''), + archiveTweet: cy.stub().resolves(), + archiveTweetCheckDate: cy.stub().resolves(), + archiveBuild: cy.stub().resolves(), syncProgress: cy.stub(), openFolder: cy.stub(), - getArchiveInfo: cy.stub(), - resetRateLimitInfo: cy.stub(), - isRateLimited: cy.stub(), - getProgress: cy.stub(), - getProgressInfo: cy.stub(), - getDatabaseStats: cy.stub(), - getDeleteReviewStats: cy.stub(), - saveProfileImage: cy.stub(), - getLatestResponseData: cy.stub(), - deleteTweetsStart: cy.stub(), - deleteTweetsCountNotArchived: cy.stub(), - deleteRetweetsStart: cy.stub(), - deleteLikesStart: cy.stub(), - deleteBookmarksStart: cy.stub(), - deleteTweet: cy.stub(), - deleteDMsMarkAllDeleted: cy.stub(), - deleteDMsScrollToBottom: cy.stub(), - verifyXArchive: cy.stub(), - importXArchive: cy.stub(), - getConfig: cy.stub(), + getArchiveInfo: cy.stub().resolves({}), + resetRateLimitInfo: cy.stub().resolves(), + isRateLimited: cy.stub().resolves({}), + getProgress: cy.stub().resolves({}), + getProgressInfo: cy.stub().resolves({}), + getDatabaseStats: cy.stub().resolves({}), + getDeleteReviewStats: cy.stub().resolves({}), + saveProfileImage: cy.stub().resolves(), + getLatestResponseData: cy.stub().resolves(null), + deleteTweetsStart: cy.stub().resolves({}), + deleteTweetsCountNotArchived: cy.stub().resolves(0), + deleteRetweetsStart: cy.stub().resolves({}), + deleteLikesStart: cy.stub().resolves({}), + deleteBookmarksStart: cy.stub().resolves({}), + deleteTweet: cy.stub().resolves(), + deleteDMsMarkAllDeleted: cy.stub().resolves(), + deleteDMsScrollToBottom: cy.stub().resolves(), + unzipXArchive: cy.stub().resolves(null), + deleteUnzippedXArchive: cy.stub().resolves(null), + verifyXArchive: cy.stub().resolves(null), + importXArchive: cy.stub().resolves({}), + getCookie: cy.stub().resolves(null), + getConfig: cy.stub().resolves(null), setConfig: cy.stub(), }, onPowerMonitorSuspend: cy.stub(), onPowerMonitorResume: cy.stub(), }; -} +}; \ No newline at end of file diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 7567a9bf..84d14bb5 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -59,6 +59,8 @@ export function getAccountIcon(accountType: string): string { // Not using the real X logo to avoid trademark issues // return "fa-brands fa-x-twitter"; return "fa-solid fa-xmark"; + case "Bluesky": + return "fa-brands fa-bluesky"; default: return "fa-solid fa-gears"; } diff --git a/src/renderer/src/views/AccountView.vue b/src/renderer/src/views/AccountView.vue index 1fde5421..910f4f41 100644 --- a/src/renderer/src/views/AccountView.vue +++ b/src/renderer/src/views/AccountView.vue @@ -55,22 +55,42 @@ onMounted(async () => { Ready to get started? Add a new account.

-