From d8f498112bca8a094d638f97e91d3749bea7f8b5 Mon Sep 17 00:00:00 2001 From: Saptak S Date: Mon, 16 Dec 2024 17:20:49 +0530 Subject: [PATCH 1/5] Allows zip files to be selected for archive imprt Unarchives the zip downloaded from twitter first into the user folder, and then uses that folder to verify and import the data. --- src/account_x.ts | 26 +++++++++++++++++-- src/main.ts | 4 ++- src/preload.ts | 5 +++- src/renderer/src/main.ts | 3 ++- .../src/views/x/XWizardImportingPage.vue | 19 +++++++++++--- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/account_x.ts b/src/account_x.ts index 8dcc5426..7a7928c2 100644 --- a/src/account_x.ts +++ b/src/account_x.ts @@ -1610,7 +1610,7 @@ export class XAccountController { ) as Sqlite3Count; const totalUnknownIndexed: Sqlite3Count = exec( this.db, - `SELECT COUNT(*) AS count FROM tweet + `SELECT COUNT(*) AS count FROM tweet WHERE id NOT IN ( SELECT id FROM tweet WHERE username = ? AND text NOT LIKE ? AND isLiked = ? UNION @@ -1750,6 +1750,19 @@ export class XAccountController { return null; } + + // Unzip twitter archive to the account data folder using unzipper + // Return unzipped path if success, else null. + async unzipXArchive(archiveZipPath: string): Promise { + const archiveZip = await unzipper.Open.file(archiveZipPath); + if (!this.account) { + return null; + } + const unzippedPath = getAccountDataPath("X", this.account.username) + await archiveZip.extract({ path: unzippedPath }); + return unzippedPath + } + // Return null on success, and a string (error message) on error async verifyXArchive(archivePath: string): Promise { const foldersToCheck = [ @@ -2538,6 +2551,15 @@ export const defineIPCX = () => { } }); + 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:verifyXArchive', async (_, accountID: number, archivePath: string): Promise => { try { const controller = getXAccountController(accountID); @@ -2582,4 +2604,4 @@ export const defineIPCX = () => { throw new Error(packageExceptionForReport(error as Error)); } }); -}; \ No newline at end of file +}; diff --git a/src/main.ts b/src/main.ts index 3f46524d..ac7bb97f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -328,8 +328,10 @@ async function createWindow() { const dataPath = database.getConfig('dataPath'); const options: Electron.OpenDialogSyncOptions = { - properties: ['openDirectory', 'createDirectory', 'promptToCreate'], + filters: [{ name: 'Archive', extensions: ['zip'] }], + properties: ['openFile'], }; + if (dataPath) { options.defaultPath = dataPath; } diff --git a/src/preload.ts b/src/preload.ts index 67f88fbf..7482c95e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -245,6 +245,9 @@ contextBridge.exposeInMainWorld('electron', { deleteDMsScrollToBottom: (accountID: number): Promise => { return ipcRenderer.invoke('X:deleteDMsScrollToBottom', accountID); }, + unzipXArchive: (accountID: number, archivePath: string): Promise => { + return ipcRenderer.invoke('X:unzipXArchive', accountID, archivePath); + }, verifyXArchive: (accountID: number, archivePath: string): Promise => { return ipcRenderer.invoke('X:verifyXArchive', accountID, archivePath); }, @@ -268,4 +271,4 @@ contextBridge.exposeInMainWorld('electron', { onPowerMonitorResume: (callback: () => void) => { ipcRenderer.on('powerMonitor:resume', callback); }, -}) \ No newline at end of file +}) diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 5d57db97..c9933e32 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -106,6 +106,7 @@ declare global { deleteTweet: (accountID: number, tweetID: string) => Promise; deleteDMsMarkAllDeleted: (accountID: number) => Promise; deleteDMsScrollToBottom: (accountID: number) => Promise; + unzipXArchive: (accountID: number, archivePath: string) => Promise; verifyXArchive: (accountID: number, archivePath: string) => Promise; importXArchive: (accountID: number, archivePath: string, dataType: string) => Promise; getCookie: (accountID: number, name: string) => Promise; @@ -122,4 +123,4 @@ const emitter = mitt(); const app = createApp(App); app.config.globalProperties.emitter = emitter; -app.mount('#app'); \ No newline at end of file +app.mount('#app'); diff --git a/src/renderer/src/views/x/XWizardImportingPage.vue b/src/renderer/src/views/x/XWizardImportingPage.vue index 5409a7cd..72ce7d86 100644 --- a/src/renderer/src/views/x/XWizardImportingPage.vue +++ b/src/renderer/src/views/x/XWizardImportingPage.vue @@ -54,9 +54,20 @@ const startClicked = async () => { errorMessages.value = []; importStarted.value = true; + // Unarchive the zip + statusValidating.value = ImportStatus.Active; + const unzippedPath: string | null = await window.electron.X.unzipXArchive(props.model.account.id, importFromArchivePath.value); + if (unzippedPath === null) { + statusValidating.value = ImportStatus.Failed; + errorMessages.value.push(unzippedPath); + importFailed.value = true; + return; + } + statusValidating.value = ImportStatus.Finished; + // Verify that the archive is valid statusValidating.value = ImportStatus.Active; - const verifyResp: string | null = await window.electron.X.verifyXArchive(props.model.account.id, importFromArchivePath.value); + const verifyResp: string | null = await window.electron.X.verifyXArchive(props.model.account.id, unzippedPath); if (verifyResp !== null) { statusValidating.value = ImportStatus.Failed; errorMessages.value.push(verifyResp); @@ -67,7 +78,7 @@ const startClicked = async () => { // Import tweets statusImportingTweets.value = ImportStatus.Active; - const tweetsResp: XImportArchiveResponse = await window.electron.X.importXArchive(props.model.account.id, importFromArchivePath.value, 'tweets'); + const tweetsResp: XImportArchiveResponse = await window.electron.X.importXArchive(props.model.account.id, unzippedPath, 'tweets'); tweetCountString.value = createCountString(tweetsResp.importCount, tweetsResp.skipCount); if (tweetsResp.status == 'error') { statusImportingTweets.value = ImportStatus.Failed; @@ -80,7 +91,7 @@ const startClicked = async () => { // Import likes statusImportingLikes.value = ImportStatus.Active; - const likesResp: XImportArchiveResponse = await window.electron.X.importXArchive(props.model.account.id, importFromArchivePath.value, 'likes'); + const likesResp: XImportArchiveResponse = await window.electron.X.importXArchive(props.model.account.id, unzippedPath, 'likes'); likeCountString.value = createCountString(likesResp.importCount, likesResp.skipCount); if (likesResp.status == 'error') { statusImportingLikes.value = ImportStatus.Failed; @@ -288,4 +299,4 @@ ul.import-status li { ul.import-status li i { margin-right: 0.5rem; } - \ No newline at end of file + From aeb26d53b9209966e5b2b044fcb78a0649ce0e4f Mon Sep 17 00:00:00 2001 From: Saptak S Date: Thu, 19 Dec 2024 17:33:44 +0530 Subject: [PATCH 2/5] Creates a separate function for selecting ZIP file --- src/main.ts | 23 ++++++++++++++++++- src/preload.ts | 3 +++ src/renderer/src/main.ts | 1 + .../src/views/x/XWizardImportingPage.vue | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index a90b32fd..b758a087 100644 --- a/src/main.ts +++ b/src/main.ts @@ -343,7 +343,7 @@ async function createWindow() { } }); - ipcMain.handle('showSelectFolderDialog', async (_): Promise => { + ipcMain.handle('showSelectZIPFileDialog', async (_): Promise => { const dataPath = database.getConfig('dataPath'); const options: Electron.OpenDialogSyncOptions = { @@ -366,6 +366,27 @@ async function createWindow() { } }); + ipcMain.handle('showSelectFolderDialog', async (_): Promise => { + const dataPath = database.getConfig('dataPath'); + + const options: Electron.OpenDialogSyncOptions = { + properties: ['openDirectory', 'createDirectory', 'promptToCreate'], + }; + if (dataPath) { + options.defaultPath = dataPath; + } + + try { + const result = dialog.showOpenDialogSync(win, options); + if (result && result.length > 0) { + return result[0]; + } + return null; + } catch (error) { + throw new Error(packageExceptionForReport(error as Error)); + } + }); + ipcMain.handle('openURL', async (_, url) => { try { shell.openExternal(url); diff --git a/src/preload.ts b/src/preload.ts index 8e0e365d..c28d2906 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -51,6 +51,9 @@ contextBridge.exposeInMainWorld('electron', { showQuestion: (message: string, trueText: string, falseText: string): Promise => { return ipcRenderer.invoke('showQuestion', message, trueText, falseText) }, + showSelectZIPFileDialog: (): Promise => { + return ipcRenderer.invoke('showSelectZIPFileDialog') + }, showSelectFolderDialog: (): Promise => { return ipcRenderer.invoke('showSelectFolderDialog') }, diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 6f7aa856..3c2d7ecd 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -38,6 +38,7 @@ declare global { showMessage: (message: string) => void; showError: (message: string) => void; showQuestion: (message: string, trueText: string, falseText: string) => Promise; + showSelectZIPFileDialog: () => Promise; showSelectFolderDialog: () => Promise; openURL: (url: string) => void; loadFileInWebview: (webContentsId: number, filename: string) => void; diff --git a/src/renderer/src/views/x/XWizardImportingPage.vue b/src/renderer/src/views/x/XWizardImportingPage.vue index 72ce7d86..f248c733 100644 --- a/src/renderer/src/views/x/XWizardImportingPage.vue +++ b/src/renderer/src/views/x/XWizardImportingPage.vue @@ -123,7 +123,7 @@ const startClicked = async () => { }; const importFromArchiveBrowserClicked = async () => { - const path = await window.electron.showSelectFolderDialog(); + const path = await window.electron.showSelectZIPFileDialog(); if (path) { importFromArchivePath.value = path; } From b688d8cd62942fcb1f7169e1d20d8c78f72b504a Mon Sep 17 00:00:00 2001 From: Saptak S Date: Thu, 19 Dec 2024 19:56:53 +0530 Subject: [PATCH 3/5] Removes the unzipped folder, once the importing steps are completed --- src/account_x.ts | 23 ++++++++++++++++++- src/preload.ts | 3 +++ src/renderer/src/main.ts | 1 + .../src/views/x/XWizardImportingPage.vue | 12 +++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/account_x.ts b/src/account_x.ts index 7a7928c2..ff8c90f2 100644 --- a/src/account_x.ts +++ b/src/account_x.ts @@ -1758,11 +1758,23 @@ export class XAccountController { if (!this.account) { return null; } - const unzippedPath = getAccountDataPath("X", this.account.username) + const unzippedPath = path.join(getAccountDataPath("X", this.account.username), path.parse(archiveZipPath).name) await archiveZip.extract({ path: unzippedPath }); return unzippedPath } + // Delete the unzipped X archive once the build is completed + async deleteUnzippedXArchive(archivePath: string): Promise { + fs.rm(archivePath, { recursive: true}, err => { + if (err) { + log.error(`XAccountController.deleteUnzippedXArchive: Error occured while deleting unzipped folder: ${err}`); + return `Error occured while deleting unzipped folder: ${err}`; + } + }); + + return null; + } + // Return null on success, and a string (error message) on error async verifyXArchive(archivePath: string): Promise { const foldersToCheck = [ @@ -2560,6 +2572,15 @@ export const defineIPCX = () => { } }); + 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); diff --git a/src/preload.ts b/src/preload.ts index c28d2906..afa72d76 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -254,6 +254,9 @@ contextBridge.exposeInMainWorld('electron', { unzipXArchive: (accountID: number, archivePath: string): Promise => { return ipcRenderer.invoke('X:unzipXArchive', accountID, archivePath); }, + deleteUnzippedXArchive: (accountID: number, archivePath: string): Promise => { + return ipcRenderer.invoke('X:deleteUnzippedXArchive', accountID, archivePath); + }, verifyXArchive: (accountID: number, archivePath: string): Promise => { return ipcRenderer.invoke('X:verifyXArchive', accountID, archivePath); }, diff --git a/src/renderer/src/main.ts b/src/renderer/src/main.ts index 3c2d7ecd..5b3a0394 100644 --- a/src/renderer/src/main.ts +++ b/src/renderer/src/main.ts @@ -109,6 +109,7 @@ declare global { deleteDMsMarkAllDeleted: (accountID: number) => Promise; deleteDMsScrollToBottom: (accountID: number) => Promise; unzipXArchive: (accountID: number, archivePath: string) => Promise; + deleteUnzippedXArchive: (accountID: number, archivePath: string) => Promise; verifyXArchive: (accountID: number, archivePath: string) => Promise; importXArchive: (accountID: number, archivePath: string, dataType: string) => Promise; getCookie: (accountID: number, name: string) => Promise; diff --git a/src/renderer/src/views/x/XWizardImportingPage.vue b/src/renderer/src/views/x/XWizardImportingPage.vue index f248c733..bf545ee6 100644 --- a/src/renderer/src/views/x/XWizardImportingPage.vue +++ b/src/renderer/src/views/x/XWizardImportingPage.vue @@ -115,11 +115,21 @@ const startClicked = async () => { emitter.emit(`x-update-archive-info-${props.model.account.id}`); statusBuildCydArchive.value = ImportStatus.Finished; + // Delete the unarchived folder whether it's success or fail + const deleteResp: string | null = await window.electron.X.deleteUnzippedXArchive(props.model.account.id, unzippedPath); + if (deleteResp !== null) { + statusValidating.value = ImportStatus.Failed; + errorMessages.value.push(verifyResp); + importFailed.value = true; + return; + } + // Success if (!importFailed.value) { await window.electron.X.setConfig(props.model.account.id, 'lastFinishedJob_importArchive', new Date().toISOString()); importFinished.value = true; } + }; const importFromArchiveBrowserClicked = async () => { @@ -173,7 +183,7 @@ const iconFromStatus = (status: ImportStatus) => {