Repository: opossum-tool/OpossumUI
Pull request #2548 (2024-01-30 07:48:09)

chore: update README.md
### Summary of changes

The section on input files is updated.

### Context and reason for change

The respective section was outdated. 

Related: #2546



Patch:
diff --git a/README.md b/README.md
index c3bc87aa..87e8b996 100644
--- a/README.md
+++ b/README.md
@@ -59,13 +59,12 @@ outputted in different formats.
 
 # <a id="user_quick_start_guide"></a> User's Quick Start Guide
 
-## Input and output files
+## Input files
 
-To work with OpossumUI, an input file can be opened to visualize and edit license compliance data of a project. When
-opening an input file for the first time, an accompanying output file with the naming convention
-"[INPUT NAME]\_attributions.json" is created in the same directory as the input file.
-
-If you open a `.opossum` file for the first time, it will be automatically updated.
+To work with OpossumUI, an input file can be opened to visualize and edit license compliance data of a project.
+Input files typically have the file ending `.opossum`, while opening the deprecated file format that has the file ending
+`.json` is still supported. In this case the app will inform the user that the deprecated file format is automatically
+converted to the new file format.
 
 ## Generating input files
 
diff --git a/package.json b/package.json
index d1f7de30..0bf09c65 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
     "@types/jest": "^29.5.11",
     "@types/js-yaml": "^4.0.9",
     "@types/lodash": "^4.14.202",
-    "@types/node": "^20.11.10",
+    "@types/node": "^20.11.8",
     "@types/object-hash": "^3.0.6",
     "@types/prop-types": "^15.7.11",
     "@types/react": "^18.2.48",
diff --git a/src/ElectronBackend/main/listeners.ts b/src/ElectronBackend/main/listeners.ts
index 94584487..087b631d 100644
--- a/src/ElectronBackend/main/listeners.ts
+++ b/src/ElectronBackend/main/listeners.ts
@@ -5,6 +5,7 @@
 // SPDX-License-Identifier: Apache-2.0
 import { BrowserWindow, shell, WebContents } from 'electron';
 import fs from 'fs';
+import hash from 'object-hash';
 import upath from 'upath';
 import zlib from 'zlib';
 
@@ -32,6 +33,7 @@ import {
   getMessageBoxForErrors,
 } from '../errorHandling/errorHandling';
 import { loadInputAndOutputFromFilePath } from '../input/importFromFile';
+import { parseOutputJsonFile } from '../input/parseFile';
 import { writeCsvToFile } from '../output/writeCsvToFile';
 import { writeSpdxFile } from '../output/writeSpdxFile';
 import {
@@ -146,6 +148,26 @@ export async function handleOpeningFile(
   await openFile(mainWindow, filePath);
 }
 
+function getActualAndParsedChecksums(resourceFilePath: string): {
+  actualInputFileChecksum: string;
+  parsedInputFileChecksum: string;
+} {
+  const manualAttributionFilePath = getFilePathWithAppendix(
+    resourceFilePath,
+    outputFileEnding,
+  );
+  const inputFileContent = fs.readFileSync(resourceFilePath, 'utf8');
+  const actualInputFileChecksum = hash.MD5(inputFileContent);
+  let parsedInputFileChecksum = '';
+
+  if (fs.existsSync(manualAttributionFilePath)) {
+    const opossumOutputData = parseOutputJsonFile(manualAttributionFilePath);
+    parsedInputFileChecksum =
+      opossumOutputData.metadata.inputFileMD5Checksum ?? '';
+  }
+  return { actualInputFileChecksum, parsedInputFileChecksum };
+}
+
 function initializeGlobalBackendState(
   filePath: string,
   isOpossumFormat: boolean,
@@ -532,6 +554,46 @@ function getOutputJson(resourceFilePath: string): string | undefined {
   return undefined;
 }
 
+export function getOpenOutdatedInputFileListener(
+  mainWindow: BrowserWindow,
+): () => Promise<void> {
+  return createListenerCallbackWithErrorHandling(mainWindow, async () => {
+    const isOpossumFormat = false;
+    const globalBackendState = getGlobalBackendState();
+    const resourceFilePath = globalBackendState.resourceFilePath;
+
+    if (!resourceFilePath) {
+      throw new Error(`Resource file path is invalid: ${resourceFilePath}`);
+    }
+
+    const checksums = getActualAndParsedChecksums(resourceFilePath);
+
+    logger.info('Updating global backend state');
+    initializeGlobalBackendState(
+      resourceFilePath,
+      isOpossumFormat,
+      checksums?.actualInputFileChecksum,
+    );
+
+    const inputFileChanged =
+      !isOpossumFormat &&
+      checksums?.parsedInputFileChecksum &&
+      checksums.actualInputFileChecksum !== checksums.parsedInputFileChecksum;
+
+    if (inputFileChanged) {
+      logger.info('Notifying user that checksum of the input file has changed');
+      mainWindow.webContents.send(
+        AllowedFrontendChannels.ShowChangedInputFilePopup,
+        {
+          showChangedInputFilePopup: true,
+        },
+      );
+    } else {
+      await openFile(mainWindow, resourceFilePath);
+    }
+  });
+}
+
 export function getOpenDotOpossumFileInsteadListener(
   mainWindow: BrowserWindow,
 ): () => Promise<void> {
diff --git a/src/ElectronBackend/main/main.ts b/src/ElectronBackend/main/main.ts
index 8e61217b..4cd574fe 100644
--- a/src/ElectronBackend/main/main.ts
+++ b/src/ElectronBackend/main/main.ts
@@ -16,6 +16,7 @@ import {
   getOpenDotOpossumFileInsteadListener,
   getOpenFileListener,
   getOpenLinkListener,
+  getOpenOutdatedInputFileListener,
   getSaveFileListener,
   getSendErrorInformationListener,
 } from './listeners';
@@ -62,6 +63,10 @@ export async function main(): Promise<void> {
       IpcChannel.ConvertInputFile,
       getConvertInputFileToDotOpossumAndOpenListener(mainWindow),
     );
+    ipcMain.handle(
+      IpcChannel.UseOutdatedInputFile,
+      getOpenOutdatedInputFileListener(mainWindow),
+    );
     ipcMain.handle(
       IpcChannel.OpenDotOpossumFile,
       getOpenDotOpossumFileInsteadListener(mainWindow),
diff --git a/src/ElectronBackend/preload.ts b/src/ElectronBackend/preload.ts
index fbf0391f..cc0d4ec1 100644
--- a/src/ElectronBackend/preload.ts
+++ b/src/ElectronBackend/preload.ts
@@ -15,6 +15,8 @@ const electronAPI: ElectronAPI = {
   keepFile: () => ipcRenderer.invoke(IpcChannel.KeepFile),
   convertInputFileToDotOpossum: () =>
     ipcRenderer.invoke(IpcChannel.ConvertInputFile),
+  useOutdatedInputFileFormat: () =>
+    ipcRenderer.invoke(IpcChannel.UseOutdatedInputFile),
   openDotOpossumFile: () => ipcRenderer.invoke(IpcChannel.OpenDotOpossumFile),
   sendErrorInformation: (errorInformationArgs) =>
     ipcRenderer.invoke(IpcChannel.SendErrorInformation, errorInformationArgs),
diff --git a/src/Frontend/Components/FileSupportDotOpossumAlreadyExistsPopup/FileSupportDotOpossumAlreadyExistsPopup.tsx b/src/Frontend/Components/FileSupportDotOpossumAlreadyExistsPopup/FileSupportDotOpossumAlreadyExistsPopup.tsx
index 35e6a8f2..216d1272 100644
--- a/src/Frontend/Components/FileSupportDotOpossumAlreadyExistsPopup/FileSupportDotOpossumAlreadyExistsPopup.tsx
+++ b/src/Frontend/Components/FileSupportDotOpossumAlreadyExistsPopup/FileSupportDotOpossumAlreadyExistsPopup.tsx
@@ -14,7 +14,9 @@ import { NotificationPopup } from '../NotificationPopup/NotificationPopup';
 const HEADER = 'Warning: Outdated input file format';
 const INFO_TEXT_PART_1 =
   'You are trying to open a file with an outdated extension (".json" or ".json.gz"). \
-    OpossumUI now reads files with a ".opossum" extension by default.';
+    OpossumUI now reads files with a ".opossum" extension by default. \
+    However, older file formats can still be opened but support may be \
+    discontinued in the future.';
 const INFO_TEXT_PART_2 =
   'A ".opossum" file with the same name of the current input file \
   was found. This file will be opened instead.';
diff --git a/src/Frontend/Components/FileSupportPopup/FileSupportPopup.tsx b/src/Frontend/Components/FileSupportPopup/FileSupportPopup.tsx
index cacf6b16..73c75555 100644
--- a/src/Frontend/Components/FileSupportPopup/FileSupportPopup.tsx
+++ b/src/Frontend/Components/FileSupportPopup/FileSupportPopup.tsx
@@ -14,10 +14,12 @@ import { NotificationPopup } from '../NotificationPopup/NotificationPopup';
 const HEADER = 'Warning: Outdated input file format';
 const INFO_TEXT_PART_1 =
   'You are trying to open a file with an outdated extension (".json" or ".json.gz"). \
-    OpossumUI now reads files with a ".opossum" extension by default.';
+    OpossumUI now reads files with a ".opossum" extension by default. \
+    However, older file formats can still be opened but support may be \
+    discontinued in the future.';
 const INFO_TEXT_PART_2 =
   'Would you like to create a new file with a ".opossum" extension from the current \
-    input file and proceed?';
+    input file and proceed (recommended), or keep working with the old format?';
 
 const classes = {
   content: {
@@ -39,6 +41,10 @@ export function FileSupportPopup(): ReactElement {
     window.electronAPI.convertInputFileToDotOpossum();
     close();
   };
+  const handleKeepButtonClick = (): void => {
+    window.electronAPI.useOutdatedInputFileFormat();
+    close();
+  };
 
   return (
     <NotificationPopup
@@ -47,6 +53,11 @@ export function FileSupportPopup(): ReactElement {
         onClick: handleCreateAndProceedButtonClick,
         buttonText: ButtonText.CreateAndProceed,
       }}
+      rightButtonConfig={{
+        onClick: handleKeepButtonClick,
+        buttonText: ButtonText.Keep,
+        color: 'secondary',
+      }}
       isOpen={true}
       content={
         <MuiBox sx={classes.content}>
diff --git a/src/shared/shared-types.ts b/src/shared/shared-types.ts
index ee79e4c3..1d06004d 100644
--- a/src/shared/shared-types.ts
+++ b/src/shared/shared-types.ts
@@ -245,6 +245,7 @@ export interface ElectronAPI {
   deleteFile: () => Promise<unknown>;
   keepFile: () => Promise<unknown>;
   convertInputFileToDotOpossum: () => void;
+  useOutdatedInputFileFormat: () => void;
   openDotOpossumFile: () => void;
   sendErrorInformation: (
     errorInformationArgs: SendErrorInformationArgs,
diff --git a/yarn.lock b/yarn.lock
index fc3d54d7..d2e4d687 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3628,12 +3628,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/node@npm:*, @types/node@npm:^20.11.10":
-  version: 20.11.10
-  resolution: "@types/node@npm:20.11.10"
+"@types/node@npm:*, @types/node@npm:^20.11.8":
+  version: 20.11.9
+  resolution: "@types/node@npm:20.11.9"
   dependencies:
     undici-types: "npm:~5.26.4"
-  checksum: 41ca9c7e7c95bf070ad747ab266f267f41950e01f5ddda739d0665c2ed39acc45cce46f78e33ffa2bf2dacd74a8493044b967c86358017dacce12958e49db664
+  checksum: 5683ec162f7975119b9c9b026f00bfc8e4fcd02d87ba106787685765fe042b8c16b71241baec043b6ac19caa343fe4e0edcb252c74943c36b98fadd72c8924fe
   languageName: node
   linkType: hard
 
@@ -9893,7 +9893,7 @@ __metadata:
     "@types/jest": "npm:^29.5.11"
     "@types/js-yaml": "npm:^4.0.9"
     "@types/lodash": "npm:^4.14.202"
-    "@types/node": "npm:^20.11.10"
+    "@types/node": "npm:^20.11.8"
     "@types/object-hash": "npm:^3.0.6"
     "@types/prop-types": "npm:^15.7.11"
     "@types/react": "npm:^18.2.48"


Test patch:
diff --git a/src/ElectronBackend/main/__tests__/listeners.test.ts b/src/ElectronBackend/main/__tests__/listeners.test.ts
index e3f547d8..aadcff61 100644
--- a/src/ElectronBackend/main/__tests__/listeners.test.ts
+++ b/src/ElectronBackend/main/__tests__/listeners.test.ts
@@ -7,6 +7,7 @@ import { BrowserWindow, dialog, shell, WebContents } from 'electron';
 import { strFromU8, unzip } from 'fflate';
 import fs from 'fs';
 
+import { EMPTY_PROJECT_METADATA } from '../../../Frontend/shared-constants';
 import {
   AllowedFrontendChannels,
   IpcChannel,
@@ -17,12 +18,14 @@ import {
   ExportSpdxDocumentJsonArgs,
   ExportSpdxDocumentYamlArgs,
   ExportType,
+  PackageInfo,
 } from '../../../shared/shared-types';
 import { writeFile } from '../../../shared/write-file';
 import { faker } from '../../../testing/Faker';
 import { loadInputAndOutputFromFilePath } from '../../input/importFromFile';
 import { writeCsvToFile } from '../../output/writeCsvToFile';
 import { writeSpdxFile } from '../../output/writeSpdxFile';
+import { OpossumOutputFile, ParsedOpossumInputFile } from '../../types/types';
 import { createWindow } from '../createWindow';
 import { openFileDialog, selectBaseURLDialog } from '../dialogs';
 import { setGlobalBackendState } from '../globalBackendState';
@@ -35,6 +38,7 @@ import {
   getOpenDotOpossumFileInsteadListener,
   getOpenFileListener,
   getOpenLinkListener,
+  getOpenOutdatedInputFileListener,
   getSelectBaseURLListener,
   linkHasHttpSchema,
 } from '../listeners';
@@ -213,6 +217,127 @@ describe('getOpenFileListener', () => {
   });
 });
 
+describe('getUseOutdatedInputFileFormatListener', () => {
+  it('handles encoding correctly', async () => {
+    const seed = faker.string.uuid();
+    const fileName = `${seed}%20with%2Fencoding`;
+    const mainWindow = {
+      webContents: {
+        send: jest.fn(),
+      },
+      setTitle: jest.fn(),
+    } as unknown as BrowserWindow;
+
+    const jsonPath = await writeFile({
+      content: faker.string.sample(),
+      path: faker.outputPath(`${fileName}.json`),
+    });
+
+    setGlobalBackendState({
+      resourceFilePath: jsonPath,
+    });
+
+    await getOpenOutdatedInputFileListener(mainWindow)();
+
+    expect(loadInputAndOutputFromFilePath).toHaveBeenCalledWith(
+      expect.anything(),
+      jsonPath,
+    );
+    expect(mainWindow.setTitle).toHaveBeenCalledWith(
+      `${seed} with/encoding.json`,
+    );
+  });
+
+  it('checks the case with non-matching checksums', async () => {
+    const mainWindow = {
+      webContents: {
+        send: jest.fn(),
+      },
+      setTitle: jest.fn(),
+    } as unknown as BrowserWindow;
+
+    const validAttribution: PackageInfo = {
+      packageName: 'Package',
+      packageVersion: '1.0',
+      licenseText: 'MIT',
+      followUp: 'FOLLOW_UP',
+    };
+    const attributions_data: OpossumOutputFile = {
+      metadata: {
+        projectId: 'test_id',
+        fileCreationDate: '1',
+        inputFileMD5Checksum: 'fake_checksum',
+      },
+      manualAttributions: {
+        test_uuid: validAttribution,
+      },
+      resourcesToAttributions: {
+        '/path/1': ['test_uuid'],
+      },
+      resolvedExternalAttributions: [],
+    };
+
+    const fileName = faker.string.uuid();
+    await writeFile({
+      path: faker.outputPath(`${fileName}_attributions.json`),
+      content: attributions_data,
+    });
+    const jsonPath = await writeFile({
+      path: faker.outputPath(`${fileName}.json`),
+      content: {},
+    });
+
+    setGlobalBackendState({
+      resourceFilePath: jsonPath,
+    });
+
+    await getOpenOutdatedInputFileListener(mainWindow)();
+
+    expect(mainWindow.webContents.send).toHaveBeenCalledWith(
+      AllowedFrontendChannels.ShowChangedInputFilePopup,
+      {
+        showChangedInputFilePopup: true,
+      },
+    );
+  });
+
+  it('sets title to project title if available', async () => {
+    const mainWindow = {
+      webContents: {
+        send: jest.fn(),
+      },
+      setTitle: jest.fn(),
+    } as unknown as BrowserWindow;
+
+    (loadInputAndOutputFromFilePath as jest.Mock).mockImplementationOnce(
+      jest.requireActual('../../input/importFromFile')
+        .loadInputAndOutputFromFilePath,
+    );
+
+    const inputFileContent: ParsedOpossumInputFile = {
+      metadata: {
+        ...EMPTY_PROJECT_METADATA,
+        projectTitle: 'Test Title',
+      },
+      resources: {},
+      externalAttributions: {},
+      frequentLicenses: [],
+      resourcesToAttributions: {},
+      externalAttributionSources: {},
+    };
+
+    setGlobalBackendState({
+      resourceFilePath: await writeFile({
+        content: inputFileContent,
+        path: faker.outputPath('path.json'),
+      }),
+    });
+
+    await getOpenOutdatedInputFileListener(mainWindow)();
+    expect(mainWindow.setTitle).toHaveBeenCalledWith('Test Title');
+  });
+});
+
 describe('getConvertInputFileToDotOpossumListener', () => {
   it('converts outdated input file formats correctly and loads .opossum', async () => {
     const mainWindow = {
diff --git a/src/Frontend/Components/ChangedInputFilePopup/__tests__/ChangedInputFilePopup.test.tsx b/src/Frontend/Components/ChangedInputFilePopup/__tests__/ChangedInputFilePopup.test.tsx
index ca91d8b7..49008d59 100644
--- a/src/Frontend/Components/ChangedInputFilePopup/__tests__/ChangedInputFilePopup.test.tsx
+++ b/src/Frontend/Components/ChangedInputFilePopup/__tests__/ChangedInputFilePopup.test.tsx
@@ -23,4 +23,18 @@ describe('ChangedInputFilePopup', () => {
     expect(window.electronAPI.deleteFile).toHaveBeenCalledTimes(1);
     expect(getOpenPopup(store.getState())).toBeNull();
   });
+
+  it('renders a ChangedInputFilePopup and clicks Keep', () => {
+    const content =
+      'The input file has changed. Do you want to keep the old attribution file or delete it?';
+
+    const { store } = renderComponent(<ChangedInputFilePopup />);
+    expect(screen.getByText('Warning')).toBeInTheDocument();
+    expect(screen.getByText(content)).toBeInTheDocument();
+
+    expect(screen.getByText(ButtonText.Keep)).toBeInTheDocument();
+    fireEvent.click(screen.getByText(ButtonText.Keep));
+    expect(window.electronAPI.keepFile).toHaveBeenCalledTimes(1);
+    expect(getOpenPopup(store.getState())).toBeNull();
+  });
 });
diff --git a/src/Frontend/Components/FileSupportPopup/__tests__/FileSupportPopup.test.tsx b/src/Frontend/Components/FileSupportPopup/__tests__/FileSupportPopup.test.tsx
index 009dc97c..9005ee33 100644
--- a/src/Frontend/Components/FileSupportPopup/__tests__/FileSupportPopup.test.tsx
+++ b/src/Frontend/Components/FileSupportPopup/__tests__/FileSupportPopup.test.tsx
@@ -24,4 +24,12 @@ describe('FileSupportPopup', () => {
       global.window.electronAPI.convertInputFileToDotOpossum,
     ).toHaveBeenCalled();
   });
+
+  it('sends correct signal to backend when clicking keepButton', () => {
+    renderComponent(<FileSupportPopup />);
+    fireEvent.click(screen.getByRole('button', { name: ButtonText.Keep }));
+    expect(
+      global.window.electronAPI.useOutdatedInputFileFormat,
+    ).toHaveBeenCalled();
+  });
 });
diff --git a/src/e2e-tests/__tests__/handling-file-deprecation-warnings.test.ts b/src/e2e-tests/__tests__/handling-file-deprecation-warnings.test.ts
index a7ad7244..3b50bf2c 100644
--- a/src/e2e-tests/__tests__/handling-file-deprecation-warnings.test.ts
+++ b/src/e2e-tests/__tests__/handling-file-deprecation-warnings.test.ts
@@ -11,6 +11,14 @@ test.use({
   },
 });
 
+test('allows user to keep outdated extension when user opens deprecated file', async ({
+  fileSupportPopup,
+}) => {
+  await fileSupportPopup.assert.titleIsVisible();
+  await fileSupportPopup.keepOutdated();
+  await fileSupportPopup.assert.titleIsHidden();
+});
+
 test('allows user to switch to .opossum extension when user opens deprecated file', async ({
   fileSupportPopup,
 }) => {
diff --git a/src/e2e-tests/page-objects/FileSupportPopup.ts b/src/e2e-tests/page-objects/FileSupportPopup.ts
index c6fea61f..9158e63d 100644
--- a/src/e2e-tests/page-objects/FileSupportPopup.ts
+++ b/src/e2e-tests/page-objects/FileSupportPopup.ts
@@ -28,6 +28,10 @@ export class FileSupportPopup {
     },
   };
 
+  async keepOutdated(): Promise<void> {
+    await this.keepButton.click();
+  }
+
   async convertToNew(): Promise<void> {
     await this.convertButton.click();
   }
diff --git a/src/testing/setup-tests.ts b/src/testing/setup-tests.ts
index 4dba1862..7811100b 100644
--- a/src/testing/setup-tests.ts
+++ b/src/testing/setup-tests.ts
@@ -37,6 +37,7 @@ global.window.electronAPI = {
   deleteFile: jest.fn(),
   keepFile: jest.fn(),
   convertInputFileToDotOpossum: jest.fn(),
+  useOutdatedInputFileFormat: jest.fn(),
   openDotOpossumFile: jest.fn(),
   sendErrorInformation: jest.fn(),
   exportFile: jest.fn(),