diff --git a/src/account_x.ts b/src/account_x.ts index 8dcc5426..2322fce3 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,28 @@ 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 = 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, force: true }, err => { + if (err) { + log.error(`XAccountController.deleteUnzippedXArchive: Error occured while deleting unzipped folder: ${err}`); + } + }); + } + // Return null on success, and a string (error message) on error async verifyXArchive(archivePath: string): Promise { const foldersToCheck = [ @@ -2538,6 +2560,24 @@ 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: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); @@ -2582,4 +2622,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 92ac6b37..b758a087 100644 --- a/src/main.ts +++ b/src/main.ts @@ -343,6 +343,29 @@ async function createWindow() { } }); + ipcMain.handle('showSelectZIPFileDialog', async (_): Promise => { + const dataPath = database.getConfig('dataPath'); + + const options: Electron.OpenDialogSyncOptions = { + filters: [{ name: 'Archive', extensions: ['zip'] }], + properties: ['openFile'], + }; + + 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('showSelectFolderDialog', async (_): Promise => { const dataPath = database.getConfig('dataPath'); diff --git a/src/preload.ts b/src/preload.ts index f38c3638..afa72d76 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') }, @@ -248,6 +251,12 @@ 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); + }, + 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); }, @@ -271,4 +280,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 f8eac8f1..5b3a0394 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; @@ -107,6 +108,8 @@ declare global { deleteTweet: (accountID: number, tweetID: string) => Promise; 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; @@ -123,4 +126,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..0eb0ee5e 100644 --- a/src/renderer/src/views/x/XWizardImportingPage.vue +++ b/src/renderer/src/views/x/XWizardImportingPage.vue @@ -54,20 +54,32 @@ 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); importFailed.value = true; + await window.electron.X.deleteUnzippedXArchive(props.model.account.id, unzippedPath); return; } statusValidating.value = ImportStatus.Finished; // 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 +92,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; @@ -99,20 +111,25 @@ const startClicked = async () => { statusBuildCydArchive.value = ImportStatus.Failed; errorMessages.value.push(`${e}`); importFailed.value = true; + await window.electron.X.deleteUnzippedXArchive(props.model.account.id, unzippedPath); return; } emitter.emit(`x-update-archive-info-${props.model.account.id}`); statusBuildCydArchive.value = ImportStatus.Finished; + // Delete the unarchived folder whether it's success or fail + await window.electron.X.deleteUnzippedXArchive(props.model.account.id, unzippedPath); + // Success if (!importFailed.value) { await window.electron.X.setConfig(props.model.account.id, 'lastFinishedJob_importArchive', new Date().toISOString()); importFinished.value = true; } + }; const importFromArchiveBrowserClicked = async () => { - const path = await window.electron.showSelectFolderDialog(); + const path = await window.electron.showSelectZIPFileDialog(); if (path) { importFromArchivePath.value = path; } @@ -162,7 +179,7 @@ const iconFromStatus = (status: ImportStatus) => {