This is an automated email from the ASF dual-hosted git repository.

voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 6b40d97eb0 [ZEPPELIN-6633] Add ipynb export to the Angular notebook UI
6b40d97eb0 is described below

commit 6b40d97eb00a47aab9d16ef816f3bbda91289585
Author: Minho Jang <[email protected]>
AuthorDate: Wed Nov 4 01:31:02 2026 +0900

    [ZEPPELIN-6633] Add ipynb export to the Angular notebook UI
    
    ### What is this PR for?
    The Angular notebook UI only supports exporting notes as `.zpln`, while the 
classic UI also supports Jupyter notebook (`.ipynb`) export.
    
    This PR adds an `.ipynb` export button to the Angular notebook toolbar. It 
uses the existing server-side conversion protocol and adds the corresponding 
SDK message types. The two export buttons have distinct tooltips and accessible 
names.
    
    Existing `.zpln` export behavior is unchanged. Both buttons are hidden in 
report mode and disabled when viewing a revision.
    
    
    ### What type of PR is it?
    Improvement
    
    ### Todos
    * [x]  - Add SDK message types and a request method for notebook conversion.
    * [x]  - Add the ipynb export button and download handling.
    * [x]  - Add type-contract and E2E regression coverage.
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6633
    
    ### How should this be tested?
    
    Run the following commands from `zeppelin-web-angular`:
    
    ```bash
    npm run typecheck:sdk-contracts
    npm run check:websocket-contract
    npm run test:shell
    ```
    
    With Zeppelin running at `http://localhost:8080`, run:
    
    ```bash
    npm run e2e:fast -- 
tests/notebook/action-bar/action-bar-functionality.spec.ts
    ```
    
    The export regression test verifies the downloaded notebook’s cells, 
Unicode content and filename, and absence of a UTF-8 BOM. It also checks that 
export still works without duplicate downloads after navigating away and 
reopening the note, and that `.zpln` export preserves the original note.
    
    For manual verification:
    
    1. Open a note containing markdown and code paragraphs in the Angular UI.
    2. Click **Export this note (ipynb)**. Open the downloaded file in Jupyter 
and check the cells and content.
    3. Navigate away and reopen the note without refreshing the browser. Export 
again and check that only one file is downloaded.
    4. Use **Export this note (zpln)** to check that Zeppelin-format export 
still works.
    5. Switch to report mode and check that both export buttons are hidden. 
With revision-capable storage, open a historical revision and check that both 
buttons are disabled.
    
    ### Screenshots (if appropriate)
    <img width="1280" height="720" alt="6633-ipynb-export" 
src="https://github.com/user-attachments/assets/5a781d00-6f77-4b83-9110-e11596964266";
 />
    
    
    ### Questions:
    
    - Does the license files need to update? No.
    - Is there breaking changes for older versions? No.
    - Does this needs documentation? No.
    
    Closes #5597 from miinhho/ZEPPELIN-6633-ipynb-export.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../e2e/models/notebook-action-bar-page.ts         |   4 +-
 .../action-bar/action-bar-functionality.spec.ts    | 146 ++++++++++++++++++++-
 .../message-data-type-map.interface.spec.ts        |  26 +++-
 .../interfaces/message-data-type-map.interface.ts  |   4 +
 .../src/interfaces/message-notebook.interface.ts   |  10 ++
 .../projects/zeppelin-sdk/src/message.ts           |   4 +
 .../notebook/action-bar/action-bar.component.html  |  19 ++-
 .../notebook/action-bar/action-bar.component.ts    |   9 ++
 8 files changed, 214 insertions(+), 8 deletions(-)

diff --git a/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts 
b/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts
index 3ffe207727..b2aaa37146 100644
--- a/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts
+++ b/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts
@@ -22,6 +22,7 @@ export class NotebookActionBarPage extends BasePage {
   readonly clearOutputButton: Locator;
   readonly cloneButton: Locator;
   readonly exportButton: Locator;
+  readonly exportIpynbButton: Locator;
   readonly reloadButton: Locator;
   readonly commitButton: Locator;
   readonly setRevisionButton: Locator;
@@ -46,7 +47,8 @@ export class NotebookActionBarPage extends BasePage {
     this.showHideOutputButton = page.locator('button[nzTooltipTitle="Show/hide 
the output"]');
     this.clearOutputButton = page.locator('button[nzTooltipTitle="Clear all 
output"]');
     this.cloneButton = page.locator('button[nzTooltipTitle="Clone this 
note"]');
-    this.exportButton = page.locator('button[nzTooltipTitle="Export this 
note"]');
+    this.exportButton = page.getByRole('button', { name: 'Export this note 
(zpln)', exact: true });
+    this.exportIpynbButton = page.getByRole('button', { name: 'Export this 
note (ipynb)', exact: true });
     this.reloadButton = page.locator('button[nzTooltipTitle="Reload from note 
file"]');
     this.commitButton = page.getByRole('button', { name: 'Commit' });
     this.setRevisionButton = page.getByRole('button', { name: 'Set as default 
revision' });
diff --git 
a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
 
b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
index 8ae9749bc0..44fef298e9 100644
--- 
a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
+++ 
b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
@@ -10,16 +10,46 @@
  * limitations under the License.
  */
 
-import { expect, test } from '@playwright/test';
+/// <reference lib="es2024.promise" />
+
+import { readFile } from 'node:fs/promises';
+import { Download, expect, test } from '@playwright/test';
 import { NotebookActionBarPage } from 
'../../../models/notebook-action-bar-page';
+import { deleteNote } from '../../../models/notebook-core-read-page.util';
 import {
   addPageAnnotationBeforeEach,
   waitForZeppelinReady,
   PAGES,
-  createTestNotebook,
-  navigateToNotebookWithFallback
+  createTestNotebookWithName,
+  navigateToNotebookWithFallback,
+  setParagraphText
 } from '../../../utils';
 
+const MARKDOWN = '# Export regression 한글 📝\nMarkdown keeps café and 日本語.';
+const CODE = 'val message = "Hello 🌍"\nprintln(message)';
+const PARAGRAPHS = [`%md\n${MARKDOWN}`, CODE];
+
+interface ExportedNbformat {
+  nbformat: number;
+  nbformat_minor: number;
+  metadata: { name: string };
+  cells: Array<{ cell_type: string; source: string | string[]; outputs?: 
unknown[] }>;
+}
+
+interface ExportedZeppelinNote {
+  id: string;
+  name: string;
+  paragraphs: Array<{ text: string }>;
+}
+
+const readDownload = async (download: Download): Promise<string> => {
+  const file = await download.path();
+  if (!file) {
+    throw new Error('The notebook export did not produce a local file');
+  }
+  return readFile(file, 'utf8');
+};
+
 test.describe('Notebook Action Bar Functionality', () => {
   // JUSTIFIED: page objects and notebook ids are stored in describe scope; 
fullyParallel can overwrite them.
   test.describe.configure({ mode: 'default' });
@@ -27,18 +57,22 @@ test.describe('Notebook Action Bar Functionality', () => {
   addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ACTION_BAR);
 
   let actionBarPage: NotebookActionBarPage;
-  let testNotebook: { noteId: string; paragraphId: string };
+  let testNotebook: { noteId: string; paragraphId: string; notebookName: 
string };
 
   test.beforeEach(async ({ page }) => {
     await page.goto('/#/');
     await waitForZeppelinReady(page);
 
-    testNotebook = await createTestNotebook(page);
+    testNotebook = await createTestNotebookWithName(page, { namePrefix: 
'TestNotebook_한글' });
     actionBarPage = new NotebookActionBarPage(page);
 
     await navigateToNotebookWithFallback(page, testNotebook.noteId);
   });
 
+  test.afterEach(async ({ page }) => {
+    await deleteNote(page.request, testNotebook?.noteId);
+  });
+
   test('should display and allow title editing with tooltip', { tag: 
'@NB-PARITY-002' }, async ({ page }) => {
     const notebookName = `TestNotebook_${Date.now()}`;
 
@@ -147,6 +181,108 @@ test.describe('Notebook Action Bar Functionality', () => {
     await expect(actionBarPage.reloadButton).toBeEnabled();
   });
 
+  test('downloads converted ipynb once per click after SPA reopen and 
preserves zpln export', async ({ page }) => {
+    const notebook = testNotebook;
+    const downloads: Download[] = [];
+    const recordDownload = (download: Download): void => {
+      downloads.push(download);
+    };
+    page.on('download', recordDownload);
+
+    try {
+      await test.step('Given a saved note with deterministic Unicode markdown 
and code', async () => {
+        await setParagraphText(page, notebook.noteId, notebook.paragraphId, 
PARAGRAPHS[0]);
+        const response = await 
page.request.post(`/api/notebook/${notebook.noteId}/paragraph`, {
+          data: { text: PARAGRAPHS[1] }
+        });
+        expect(response.ok(), `Code paragraph creation failed 
(${response.status()}): ${await response.text()}`).toBe(
+          true
+        );
+        await navigateToNotebookWithFallback(page, notebook.noteId);
+        await 
expect(actionBarPage.titleEditor).toHaveText(notebook.notebookName);
+      });
+
+      await test.step('Then both export formats are available with distinct 
accessible names', async () => {
+        await expect(actionBarPage.exportIpynbButton).toBeVisible();
+        await expect(actionBarPage.exportIpynbButton).toBeEnabled();
+        await expect(actionBarPage.exportButton).toBeVisible();
+        await expect(actionBarPage.exportButton).toBeEnabled();
+      });
+
+      const exportAndVerifyIpynb = async (): Promise<void> => {
+        const downloadEvent = page.waitForEvent('download');
+        await actionBarPage.exportIpynbButton.click();
+        const download = await downloadEvent;
+        
expect(download.suggestedFilename()).toBe(`${notebook.notebookName}.ipynb`);
+        const content = await readDownload(download);
+        expect(content.charCodeAt(0)).not.toBe(0xfeff);
+        const converted = JSON.parse(content) as ExportedNbformat;
+        expect(converted.nbformat).toBe(4);
+        expect(converted.metadata.name).toBe(notebook.notebookName);
+        expect(
+          converted.cells.map(cell => ({
+            type: cell.cell_type,
+            source: Array.isArray(cell.source) ? cell.source.join('') : 
cell.source
+          }))
+        ).toEqual([
+          { type: 'markdown', source: MARKDOWN },
+          { type: 'code', source: CODE }
+        ]);
+        expect(converted.cells[1].outputs).toEqual([]);
+      };
+
+      await test.step(
+        'When ipynb is exported, then it contains real converted Unicode cells 
without a BOM',
+        exportAndVerifyIpynb
+      );
+
+      await test.step('When the user leaves the notebook through SPA 
navigation and returns', async () => {
+        await page.evaluate(() => {
+          window.location.hash = '/';
+        });
+        await expect(actionBarPage.exportIpynbButton).toHaveCount(0);
+        await waitForZeppelinReady(page);
+        await page.evaluate(id => {
+          window.location.hash = `/notebook/${id}`;
+        }, notebook.noteId);
+        await waitForZeppelinReady(page);
+        await 
expect(actionBarPage.titleEditor).toHaveText(notebook.notebookName);
+        await expect(actionBarPage.exportIpynbButton).toBeVisible();
+      });
+
+      await test.step(
+        'When ipynb is exported again, then the reopened note downloads the 
same converted cells',
+        exportAndVerifyIpynb
+      );
+
+      await test.step('Then zpln still downloads the original Zeppelin note 
and no duplicate ipynb download occurred', async () => {
+        const downloadEvent = page.waitForEvent('download');
+        await actionBarPage.exportButton.click();
+        const download = await downloadEvent;
+        
expect(download.suggestedFilename()).toBe(`${notebook.notebookName}.zpln`);
+        const content = await readDownload(download);
+        expect(content.charCodeAt(0)).not.toBe(0xfeff);
+        const exported = JSON.parse(content) as ExportedZeppelinNote;
+        expect(exported.id).toBe(notebook.noteId);
+        expect(exported.name).toBe(notebook.notebookName);
+        expect(exported.paragraphs.map(paragraph => 
paragraph.text)).toEqual(PARAGRAPHS);
+        // Completed files and the next render task bound synchronous response 
listeners without a sleep.
+        await page.evaluate(() => {
+          const { promise, resolve } = Promise.withResolvers<void>();
+          requestAnimationFrame(() => resolve());
+          return promise;
+        });
+        expect(downloads.map(item => item.suggestedFilename())).toEqual([
+          `${notebook.notebookName}.ipynb`,
+          `${notebook.notebookName}.ipynb`,
+          `${notebook.notebookName}.zpln`
+        ]);
+      });
+    } finally {
+      page.off('download', recordDownload);
+    }
+  });
+
   // Toggle coverage lives in collaboration/collaborative-mode.spec.ts.
 
   test('should handle revision controls when supported', async () => {
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.spec.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.spec.ts
index f4013f2c8c..ef2d52d966 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.spec.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.spec.ts
@@ -12,7 +12,8 @@
 
 import { assertType, expectTypeOf, it } from 'vitest';
 
-import { MessageReceiveDataTypeMap } from './message-data-type-map.interface';
+import { MessageReceiveDataTypeMap, MessageSendDataTypeMap } from 
'./message-data-type-map.interface';
+import { ConvertNote, ConvertedNote } from './message-notebook.interface';
 import { OP } from './message-operator.interface';
 import { DatasetType, ParagraphAppendOutput, ParagraphUpdateOutput } from 
'./message-paragraph.interface';
 
@@ -34,3 +35,26 @@ it('declares the asymmetric paragraph output payloads sent 
by the server', () =>
     .toHaveProperty('type')
     .toEqualTypeOf<DatasetType>();
 });
+
+it('requires the notebook conversion request fields and a serialized 
response', () => {
+  assertType<MessageSendDataTypeMap[OP.CONVERT_NOTE_NBFORMAT]>({
+    noteId: 'note',
+    noteName: 'folder/notebook'
+  });
+  assertType<MessageReceiveDataTypeMap[OP.CONVERTED_NOTE_NBFORMAT]>({
+    nbformat: '{"cells":[],"nbformat":4,"nbformat_minor":0}',
+    noteName: 'folder/notebook'
+  });
+  
expectTypeOf<MessageSendDataTypeMap[OP.CONVERT_NOTE_NBFORMAT]>().toEqualTypeOf<ConvertNote>();
+  
expectTypeOf<MessageSendDataTypeMap[OP.CONVERT_NOTE_NBFORMAT]>().toEqualTypeOf<{
+    noteId: string;
+    noteName: string;
+  }>();
+  
expectTypeOf<MessageReceiveDataTypeMap[OP.CONVERTED_NOTE_NBFORMAT]>().toEqualTypeOf<ConvertedNote>();
+  
expectTypeOf<MessageReceiveDataTypeMap[OP.CONVERTED_NOTE_NBFORMAT]>().toEqualTypeOf<{
+    nbformat: string;
+    noteName: string;
+  }>();
+  expectTypeOf<{ noteId: string }>().not.toMatchTypeOf<ConvertNote>();
+  expectTypeOf<{ nbformat: { cells: [] }; noteName: string 
}>().not.toMatchTypeOf<ConvertedNote>();
+});
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
index dfa7913375..1e94acdb84 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
@@ -16,6 +16,8 @@ import {
   CheckpointNote,
   CloneNote,
   CollaborativeModeStatus,
+  ConvertNote,
+  ConvertedNote,
   DeleteNote,
   EditorSettingReceived,
   EditorSettingSend,
@@ -95,6 +97,7 @@ export interface MessageReceiveDataTypeMap {
   [OP.COMPLETION_LIST]: CompletionReceived;
   [OP.NOTES_INFO]: NotesInfo;
   [OP.NOTE]: Note;
+  [OP.CONVERTED_NOTE_NBFORMAT]: ConvertedNote;
   [OP.NOTE_REVISION]: NoteRevision;
   [OP.ERROR_INFO]: ErrorInfo;
   [OP.LIST_NOTE_JOBS]: ListNoteJobs;
@@ -145,6 +148,7 @@ export interface MessageSendDataTypeMap {
   [OP.REMOVE_FOLDER]: RemoveFolder;
   [OP.DEL_NOTE]: DeleteNote;
   [OP.CLONE_NOTE]: CloneNote;
+  [OP.CONVERT_NOTE_NBFORMAT]: ConvertNote;
   [OP.NOTE_UPDATE]: NoteUpdate;
   [OP.UPDATE_PERSONALIZED_MODE]: UpdatePersonalizedMode;
   [OP.NOTE_RENAME]: NoteRename;
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts
index 1e3bea95f0..65b2b0e3a8 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-notebook.interface.ts
@@ -32,6 +32,16 @@ export type CloneNote = ID & Name;
 export type FolderRename = ID & Name;
 export type PersonalizedMode = 'true' | 'false';
 
+export interface ConvertNote {
+  noteId: string;
+  noteName: string;
+}
+
+export interface ConvertedNote {
+  nbformat: string;
+  noteName: string;
+}
+
 export interface NoteRename extends Name, ID {
   relative?: boolean;
 }
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
index 79e27e3e9a..21fb29bb94 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
@@ -256,6 +256,10 @@ export class Message {
     this.send<OP.CLONE_NOTE>(OP.CLONE_NOTE, { id: noteIdToClone, name: 
newNoteName });
   }
 
+  convertNote(noteId: string, noteName: string): void {
+    this.send<OP.CONVERT_NOTE_NBFORMAT>(OP.CONVERT_NOTE_NBFORMAT, { noteId, 
noteName });
+  }
+
   /**
    * get nodes list
    */
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html
index c45438e872..d11917cb00 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html
@@ -85,7 +85,24 @@
         </button>
       }
       @if (!viewOnly) {
-        <button nz-button nz-tooltip nzTooltipTitle="Export this note" 
(click)="exportNote()" [disabled]="revisionView">
+        <button
+          nz-button
+          nz-tooltip
+          nzTooltipTitle="Export this note (zpln)"
+          aria-label="Export this note (zpln)"
+          (click)="exportNote()"
+          [disabled]="revisionView"
+        >
+          <i nz-icon nzType="download" nzTheme="outline"></i>
+        </button>
+        <button
+          nz-button
+          nz-tooltip
+          nzTooltipTitle="Export this note (ipynb)"
+          aria-label="Export this note (ipynb)"
+          (click)="exportNbformat()"
+          [disabled]="revisionView"
+        >
           <i nz-icon nzType="download" nzTheme="outline"></i>
         </button>
       }
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.ts
index 16c0ecc3b0..0bed950303 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.ts
@@ -84,6 +84,11 @@ export class NotebookActionBarComponent extends 
MessageListenersManager implemen
     { name: '1d', value: '0 0 0 * * ?' }
   ];
 
+  @MessageListener(OP.CONVERTED_NOTE_NBFORMAT)
+  saveConvertedNote(data: 
MessageReceiveDataTypeMap[OP.CONVERTED_NOTE_NBFORMAT]) {
+    this.saveAsService.saveAs(data.nbformat, data.noteName, 'ipynb');
+  }
+
   updateNoteName(name: string) {
     const trimmedNewName = name.trim();
     if (trimmedNewName.length > 0 && this.note.name !== trimmedNewName) {
@@ -218,6 +223,10 @@ export class NotebookActionBarComponent extends 
MessageListenersManager implemen
     }
   }
 
+  exportNbformat() {
+    this.messageService.convertNote(this.note.id, this.note.name);
+  }
+
   reloadNote() {
     this.messageService.reloadNote(this.note.id);
   }

Reply via email to