diff --git a/.gitignore b/.gitignore index c4885ecd..e2723ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -## Ignore Visual Studio temporary files, build results, and +## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. # User-specific files @@ -245,4 +245,7 @@ settings.json src/Analysim.Web/Logs # -.DS_Store + + + + diff --git a/src/Analysim.Web/ClientApp/package-lock.json b/src/Analysim.Web/ClientApp/package-lock.json index 361c055d..e82d36b6 100644 --- a/src/Analysim.Web/ClientApp/package-lock.json +++ b/src/Analysim.Web/ClientApp/package-lock.json @@ -15757,13 +15757,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.1.tgz", "integrity": "sha512-f1G1WGDXEU/RN1TWAxBPQgQudtLnLQPyiWdtypkPC+mVYNKFKH/HYXSxH4MVNqwF8M0eDsoiU7HumJHCg/L/jg==", - "dev": true + "dev": true, + "requires": {} }, "@csstools/selector-specificity": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.1.tgz", "integrity": "sha512-aG20vknL4/YjQF9BSV7ts4EWm/yrjagAN7OWBNmlbEOUiu0llj4OGrFoOKK3g2vey4/p2omKCoHrWtPxSwV3HA==", - "dev": true + "dev": true, + "requires": {} }, "@discoveryjs/json-ext": { "version": "0.5.7", @@ -15932,7 +15934,8 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.0.3.tgz", "integrity": "sha512-PwvgCeY7mbijazovpA0ggeo81A3yzwOb8AfVD3yfGT15Z2qnEVyL+05Tj6ttRTngceF3gsERamFcB6lRKdcjdw==", - "dev": true + "dev": true, + "requires": {} }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -16834,7 +16837,8 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "7.2.0", @@ -17234,7 +17238,8 @@ "bootstrap": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", - "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==" + "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", + "requires": {} }, "bootstrap-icons": { "version": "1.11.3", @@ -17921,7 +17926,8 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", - "dev": true + "dev": true, + "requires": {} }, "css-select": { "version": "4.3.0", @@ -19682,7 +19688,8 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true + "dev": true, + "requires": {} }, "ieee754": { "version": "1.2.1", @@ -20487,7 +20494,8 @@ "ws": { "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==" + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "requires": {} } } }, @@ -20711,7 +20719,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-1.7.0.tgz", "integrity": "sha512-pzum1TL7j90DTE86eFt48/s12hqwQuiD+e5aXx2Dc9wDEn2LfGq6RoAxEZZjFiN0RDSCOnosEKRZWxbQ+iMpQQ==", - "dev": true + "dev": true, + "requires": {} }, "karma-source-map-support": { "version": "1.4.0", @@ -21108,7 +21117,8 @@ "marked-highlight": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.1.3.tgz", - "integrity": "sha512-t35JWm2u8HanOJ+gSJBAYQ0Jgr3vy+gl7ORAXN8bSEQFHl5FYXH0A7YXVMrfhmKaSuBSy6LidXECn3U9Qv/dHA==" + "integrity": "sha512-t35JWm2u8HanOJ+gSJBAYQ0Jgr3vy+gl7ORAXN8bSEQFHl5FYXH0A7YXVMrfhmKaSuBSy6LidXECn3U9Qv/dHA==", + "requires": {} }, "media-typer": { "version": "0.3.0", @@ -22270,13 +22280,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "dev": true + "dev": true, + "requires": {} }, "postcss-gap-properties": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.3.tgz", "integrity": "sha512-rPPZRLPmEKgLk/KlXMqRaNkYTUpE7YC+bOIQFN5xcu1Vp11Y4faIXv6/Jpft6FMnl6YRxZqDZG0qQOW80stzxQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-image-set-function": { "version": "4.0.6", @@ -22302,7 +22314,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-lab-function": { "version": "4.2.0", @@ -22329,19 +22342,22 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", - "dev": true + "dev": true, + "requires": {} }, "postcss-media-minmax": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true + "dev": true, + "requires": {} }, "postcss-modules-local-by-default": { "version": "4.0.5", @@ -22392,13 +22408,15 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.3.tgz", "integrity": "sha512-CxZwoWup9KXzQeeIxtgOciQ00tDtnylYIlJBBODqkgS/PU2jISuWOL/mYLHmZb9ZhZiCaNKsCRiLp22dZUtNsg==", - "dev": true + "dev": true, + "requires": {} }, "postcss-page-break": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-place": { "version": "7.0.4", @@ -22475,7 +22493,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "dev": true + "dev": true, + "requires": {} }, "postcss-selector-not": { "version": "5.0.0", @@ -23036,7 +23055,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -23588,7 +23608,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", - "dev": true + "dev": true, + "requires": {} }, "stylus": { "version": "0.57.0", @@ -23748,7 +23769,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -24168,7 +24190,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -24268,7 +24291,8 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.0.tgz", "integrity": "sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==", - "dev": true + "dev": true, + "requires": {} } } }, @@ -24409,7 +24433,8 @@ "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", - "dev": true + "dev": true, + "requires": {} }, "xml-name-validator": { "version": "3.0.0", diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/notebooks/admin-notebook-item/admin-notebook-item-display/forageIndexDb.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/notebooks/admin-notebook-item/admin-notebook-item-display/forageIndexDb.ts index 82ef1128..8cd9ec20 100644 --- a/src/Analysim.Web/ClientApp/src/app/admin/components/notebooks/admin-notebook-item/admin-notebook-item-display/forageIndexDb.ts +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/notebooks/admin-notebook-item/admin-notebook-item-display/forageIndexDb.ts @@ -1,49 +1,3 @@ -import { Injectable } from '@angular/core'; -import * as localforage from 'localforage'; - -@Injectable({ - providedIn: 'root', -}) -export class JupyterLiteStorageService { - private filesStore: LocalForage; - private checkpointsStore: LocalForage; - - constructor() { - this.filesStore = localforage.createInstance({ - name: 'JupyterLite Storage', - storeName: 'files', // Object store name - description: 'Storage for JupyterLite files', - }); - this.checkpointsStore = localforage.createInstance({ - name: 'JupyterLite Storage', - storeName: 'checkpoints', // Object store name - description: 'Storage for JupyterLite checkpoints', - }); - } - - // Add a file to the IndexedDB - addFile(fileName: string, fileData: any): Promise { - return this.filesStore.setItem(fileName, fileData); - } - - // Get a file by its name - getFile(fileName: string): Promise { - return this.filesStore.getItem(fileName); - } - - getCheckpoints(fileName: string): Promise { - return this.checkpointsStore.getItem(fileName); - } - - removeFile(fileName: string): Promise { - return this.filesStore.removeItem(fileName); - } - - // Get all files - getAllFiles(): Promise { - const files: any[] = []; - return this.filesStore.iterate((value, key) => { - files.push({ key, value }); - }).then(() => files); - } -} +// JupyterLiteStorageService has been consolidated into a shared service. +// This file re-exports from the new location for backwards compatibility. +export { JupyterLiteStorageService } from '../../../../../shared/services/jupyter-lite-storage.service'; diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/localforageIndexdb.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/localforageIndexdb.ts deleted file mode 100644 index da3d2cba..00000000 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/localforageIndexdb.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable } from '@angular/core'; -import * as localforage from 'localforage'; - -@Injectable({ - providedIn: 'root', -}) -export class JupyterLiteStorageService { - private filesStore: LocalForage; - private checkpointsStore: LocalForage; - - constructor() { - this.filesStore = localforage.createInstance({ - name: 'JupyterLite Storage - /assets/jupyter/dist/', - storeName: 'files', // Object store name - description: 'Storage for JupyterLite files', - }); - this.checkpointsStore = localforage.createInstance({ - name: 'JupyterLite Storage - /assets/jupyter/dist/', - storeName: 'checkpoints', // Object store name - description: 'Storage for JupyterLite checkpoints', - }); - } - - // Add a file to the IndexedDB - addFile(fileName: string, fileData: any): Promise { - return this.filesStore.setItem(fileName, fileData); - } - - // Get a file by its name - getFile(fileName: string): Promise { - return this.filesStore.getItem(fileName); - } - - getCheckpoints(fileName: string): Promise { - return this.checkpointsStore.getItem(fileName); - } - - removeFile(fileName: string): Promise { - return this.filesStore.removeItem(fileName); - } - - // Get all files - getAllFiles(): Promise { - const files: any[] = []; - return this.filesStore.iterate((value, key) => { - files.push({ key, value }); - }).then(() => files); - } -} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/project-notebook-item-display.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/project-notebook-item-display.component.ts index 7e4791f4..d07e8785 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/project-notebook-item-display.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item-display/project-notebook-item-display.component.ts @@ -1,45 +1,63 @@ -import { Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + Renderer2, + ViewChild, +} from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Notebook, NotebookFile } from '../../../../../../interfaces/notebook'; import { ProjectService } from '../../../../../../services/project.service'; import { HttpClient } from '@angular/common/http'; -import { JupyterLiteStorageService } from './localforageIndexdb'; -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { JupyterLiteStorageService } from '../../../../../../shared/services/jupyter-lite-storage.service'; @Component({ selector: 'app-project-notebook-item-display', templateUrl: './project-notebook-item-display.component.html', - styleUrls: ['./project-notebook-item-display.component.scss'] + styleUrls: ['./project-notebook-item-display.component.scss'], }) -export class ProjectNotebookItemDisplayComponent { - +export class ProjectNotebookItemDisplayComponent implements OnInit, OnDestroy { @Input() notebook: Notebook; @Input() version: number; @Input() isMember: boolean; - @Output() closeModal: EventEmitter = new EventEmitter(); - showSaveWarningModal = false; - showSaveNotebookModal = false; - constructor(private projectService: ProjectService, private _renderer2: Renderer2, private http: HttpClient - , private sanitizer: DomSanitizer, private jupyterLiteStorageService: JupyterLiteStorageService - ) { } - - @ViewChild('observablehqPanel', { read: ElementRef }) observablehqPanel; + @ViewChild('observablehqPanel', { read: ElementRef }) observablehqPanel: ElementRef; @ViewChild('jupyterFrame') jupyterFrame: ElementRef; @ViewChild('notebookWindow') notebookWindow: ElementRef; + jupyterFrameSrc: SafeResourceUrl; isLoading = true; - timeoutId: any; - notebookFile: NotebookFile; commitChangesLoading = false; + showSaveWarningModal = false; + showSaveNotebookModal = false; + notebookFile: NotebookFile; + + private static readonly JUPYTER_BASE_PATH = 'assets/jupyter/dist/lab/index.html'; + + private timeoutId: any; + private messageHandler: (event: MessageEvent) => void; + + constructor( + private projectService: ProjectService, + private renderer: Renderer2, + private http: HttpClient, + private sanitizer: DomSanitizer, + private jupyterStorage: JupyterLiteStorageService, + ) {} ngOnInit(): void { - window.addEventListener('message', this.receiveMessage.bind(this)); + this.messageHandler = this.receiveMessage.bind(this); + window.addEventListener('message', this.messageHandler); if (this.notebook.type === 'notebook' || this.notebook.type === 'new') { this.loadNotebook(); } - this.setTimeoutForLoading(); + this.startLoadingTimeout(); } ngAfterViewInit(): void { @@ -49,190 +67,152 @@ export class ProjectNotebookItemDisplayComponent { } } - setTimeoutForLoading(): void { + ngOnDestroy(): void { + window.removeEventListener('message', this.messageHandler); + clearTimeout(this.timeoutId); + } + + private startLoadingTimeout(): void { this.timeoutId = setTimeout(() => { if (this.isLoading) { this.isLoading = false; this.closeModal.emit(); - console.log("some unknown error occurred , please open the notebook again."); - alert("some unknown error occurred , please open the notebook again."); + console.warn('JupyterLite timed out while loading.'); + alert('The notebook took too long to load. Please try opening it again.'); } }, 30000); } - loadNotebook() { - const url = `../../../../../../../assets/jupyter/dist/lab/index.html?path=${this.notebook.name}${this.notebook.extension}`; - this.jupyterFrameSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url); - //console.log("the version: ", this.version); - - this.projectService.getNotebookFile(this.notebook, this.version) - .subscribe(nbContent => { - // console.log("the notebook content is : ", nbContent); - const notebookName = `${this.notebook.name}${this.notebook.extension}`; - const notebookData = { - content: nbContent, // The content of the notebook - created: new Date().toISOString(), - format: "json", - hash: null, - hash_algorithm: null, - last_modified: new Date().toISOString(), - mimetype: 'application/x-ipynb+json', - name: notebookName, - size: this.notebook.size, - path: notebookName, - type: 'notebook', - writable: true, - }; - - // Fetch and add datasets of the notebook - let datasets = this.notebook.observableNotebookDatasets; - if (datasets) { - datasets.forEach(dataset => { - this.projectService.downloadCSV(dataset.blobFileID).subscribe(data => { - // console.log("dataset content during fetching : ", data); - const datasetName = dataset.datasetName; - const datasetData = { - content: data, // The content of the dataset - created: new Date().toISOString(), - format: "text", - last_modified: new Date().toISOString(), - mimetype: 'text/csv', - name: datasetName, - path: datasetName, - size: 0, - type: 'file', - writable: true, - } - - this.jupyterLiteStorageService.addFile(datasetName, datasetData).then( - () => { - // console.log(`Dataset ${datasetName} added successfully`); - }, - (error) => { - console.error('Error adding dataset:', error); - } - ); - }); - }); - } - - // Add the notebook - this.jupyterLiteStorageService.addFile(notebookName, notebookData).then( - () => { - console.log('File added successfully'); - }, - (error) => { - console.error('Error adding file:', error); - } - ); + private loadNotebook(): void { + const notebookFileName = `${this.notebook.name}${this.notebook.extension}`; + const iframeUrl = `${ProjectNotebookItemDisplayComponent.JUPYTER_BASE_PATH}?path=${encodeURIComponent(notebookFileName)}`; + this.jupyterFrameSrc = this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl); + + this.projectService.getNotebookFile(this.notebook, this.version).subscribe({ + next: (nbContent) => { + const notebookData = this.buildNotebookEntry(notebookFileName, nbContent); + this.storeNotebook(notebookFileName, notebookData); + this.storeDatasets(); + }, + error: (err) => console.error('Failed to fetch notebook content:', err), + }); + } + + private buildNotebookEntry(fileName: string, content: any): object { + return { + content, + name: fileName, + path: fileName, + type: 'notebook', + format: 'json', + mimetype: 'application/x-ipynb+json', + size: this.notebook.size, + writable: true, + created: new Date().toISOString(), + last_modified: new Date().toISOString(), + hash: null, + hash_algorithm: null, + }; + } + + private storeNotebook(fileName: string, data: object): void { + this.jupyterStorage.addFile(fileName, data).then( + () => console.log(`Notebook written to IndexedDB`), + (err) => console.error('Failed to write notebook to IndexedDB:', err), + ); + } + + private storeDatasets(): void { + const datasets = this.notebook.observableNotebookDatasets; + if (!datasets?.length) return; + datasets.forEach((dataset) => { + this.projectService.downloadCSV(dataset.blobFileID).subscribe({ + next: (data) => { + const entry = { + content: data, name: dataset.datasetName, path: dataset.datasetName, + type: 'file', format: 'text', mimetype: 'text/csv', size: 0, + writable: true, created: new Date().toISOString(), last_modified: new Date().toISOString(), + }; + this.jupyterStorage.addFile(dataset.datasetName, entry).then( + () => {}, + (err) => console.error(`Failed to write dataset:`, err), + ); + }, + error: (err) => console.error(`Failed to download dataset:`, err), }); + }); } receiveMessage(event: MessageEvent): void { if (event.data === 'jupyterlite-load') { this.isLoading = false; clearTimeout(this.timeoutId); - console.log('Notebook loaded successfully'); + console.log('JupyterLite signalled ready'); } } - generateObservableNotebook() { - - let script = this._renderer2.createElement('script'); - script.type = `module`; - script.text = this.generateScript; - this._renderer2.appendChild(this.observablehqPanel.nativeElement, script); + private generateObservableNotebook(): void { + const script = this.renderer.createElement('script'); + script.type = 'module'; + script.text = this.buildObservableScript(); + this.renderer.appendChild(this.observablehqPanel.nativeElement, script); } - get generateScript(): String { - return ` import {Runtime, Inspector} from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js"; - var notebookLink = "https://api.` + this.notebook.uri.replace("https://", "") + `.js?v=3"; - import(notebookLink).then((define) =>{ - var notebook = define.default; - (new Runtime).module(notebook, name =>{ - return Inspector.into("#notebook")(); - }); - });` - } - - saveNotebook() { - this.showSaveNotebookModal = true; + private buildObservableScript(): string { + const apiUri = this.notebook.uri.replace('https://', ''); + return ` + import { Runtime, Inspector } from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js"; + const notebookLink = "https://api.${apiUri}.js?v=3"; + import(notebookLink).then((define) => { + (new Runtime).module(define.default, name => Inspector.into("#notebook")()); + }); + `; } - onConfirmSaveNotebook() { - if (this.notebook.type === 'notebook' || this.notebook.type === 'new') { - this.commitChangesLoading = true; - this.jupyterLiteStorageService.getFile(`${this.notebook.name}${this.notebook.extension}`).then( - (notebookJson) => { - //console.log('File:', notebookJson); - //console.log("the projectid si :", this.notebook.projectID); - const notebookBlob = new Blob([JSON.stringify(notebookJson.content)], { type: 'application/json' }); - const file = new File([notebookBlob], `${this.notebook.name}${this.notebook.extension}`, { type: 'application/json' }); - this.notebookFile = { - 'file': file, - 'name': `${this.notebook.name}`, - 'projectID': this.notebook.projectID, - } - //console.log("the file is : ", file); - this.projectService.uploadNotebookNewVersion(this.notebookFile, this.notebook.directory).subscribe(result => { - //console.log(result); - this.commitChangesLoading = false; - this.showSaveNotebookModal = false; - }); - }, - (error) => { - console.error('Error getting file:', error); - } - ); - } + saveNotebook(): void { this.showSaveNotebookModal = true; } + + onConfirmSaveNotebook(): void { + if (this.notebook.type !== 'notebook' && this.notebook.type !== 'new') return; + this.commitChangesLoading = true; + const fileName = `${this.notebook.name}${this.notebook.extension}`; + this.jupyterStorage.getFile(fileName).then( + (notebookJson) => { + const blob = new Blob([JSON.stringify(notebookJson.content)], { type: 'application/json' }); + const file = new File([blob], fileName, { type: 'application/json' }); + this.notebookFile = { file, name: this.notebook.name, projectID: this.notebook.projectID }; + this.projectService.uploadNotebookNewVersion(this.notebookFile, this.notebook.directory).subscribe({ + next: () => { this.commitChangesLoading = false; this.showSaveNotebookModal = false; }, + error: (err) => { console.error('Upload failed:', err); this.commitChangesLoading = false; }, + }); + }, + (err) => console.error('Failed to read notebook:', err), + ); } - onCancelSaveNotebook() { - this.showSaveNotebookModal = false; - } + onCancelSaveNotebook(): void { this.showSaveNotebookModal = false; } - closeNotebook() { - this.showSaveWarningModal = true; - } + closeNotebook(): void { this.showSaveWarningModal = true; } - onConfirmSave() { + onConfirmSave(): void { clearTimeout(this.timeoutId); this.closeModal.emit(); + const fileName = `${this.notebook.name}${this.notebook.extension}`; if (this.notebook.type === 'notebook' || this.notebook.type === 'new') { - this.jupyterLiteStorageService.getFile(`${this.notebook.name}${this.notebook.extension}`).then( - (file) => { - console.log('File:', file); - }, - (error) => { - console.error('Error getting file:', error); - } - ); - this.jupyterLiteStorageService.removeFile(`${this.notebook.name}${this.notebook.extension}`).then( - () => { - console.log('File removed successfully'); - }, - (error) => { - console.error('Error removing file:', error); - } + this.jupyterStorage.removeFile(fileName).then( + () => console.log('Notebook removed from IndexedDB'), + (err) => console.error('Failed to remove notebook:', err), ); } - - let datasets = this.notebook.observableNotebookDatasets; - if (datasets) { - datasets.forEach(dataset => { - this.jupyterLiteStorageService.removeFile(dataset.datasetName).then( - () => { - console.log(`Dataset ${dataset.datasetName} removed successfully`); - }, - (error) => { - console.error('Error removing dataset:', error); - } + const datasets = this.notebook.observableNotebookDatasets; + if (datasets?.length) { + datasets.forEach((dataset) => { + this.jupyterStorage.removeFile(dataset.datasetName).then( + () => {}, + (err) => console.error(`Failed to remove dataset:`, err), ); }); } } - onCancelSave() { - this.showSaveWarningModal = false; - } + onCancelSave(): void { this.showSaveWarningModal = false; } } diff --git a/src/Analysim.Web/ClientApp/src/app/shared/services/jupyter-lite-storage.service.ts b/src/Analysim.Web/ClientApp/src/app/shared/services/jupyter-lite-storage.service.ts new file mode 100644 index 00000000..a84f1119 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/shared/services/jupyter-lite-storage.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import * as localforage from 'localforage'; + +/** + * Manages the IndexedDB storage that JupyterLite uses to read and write + * notebook files and datasets inside the browser. + * + * Previously this service existed in two separate places: + * - admin/components/notebooks/.../forageIndexDb.ts + * - projects/.../localforageIndexdb.ts + * + * They were almost identical but used slightly different IndexedDB names, + * which meant any bug fix had to be applied in two places. This single + * version replaces both. + */ +@Injectable({ + providedIn: 'root', +}) +export class JupyterLiteStorageService { + + private static readonly STORAGE_NAME = 'JupyterLite Storage - /assets/jupyter/dist/'; + + private filesStore: LocalForage; + private checkpointsStore: LocalForage; + + constructor() { + this.filesStore = localforage.createInstance({ + name: JupyterLiteStorageService.STORAGE_NAME, + storeName: 'files', + description: 'Notebook and dataset files for JupyterLite', + }); + + this.checkpointsStore = localforage.createInstance({ + name: JupyterLiteStorageService.STORAGE_NAME, + storeName: 'checkpoints', + description: 'Notebook checkpoints for JupyterLite', + }); + } + + addFile(fileName: string, fileData: any): Promise { + return this.filesStore.setItem(fileName, fileData); + } + + getFile(fileName: string): Promise { + return this.filesStore.getItem(fileName); + } + + getCheckpoints(fileName: string): Promise { + return this.checkpointsStore.getItem(fileName); + } + + removeFile(fileName: string): Promise { + return this.filesStore.removeItem(fileName); + } + + getAllFiles(): Promise> { + const files: Array<{ key: string; value: any }> = []; + return this.filesStore + .iterate((value, key) => { + files.push({ key, value }); + }) + .then(() => files); + } +} diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/index.js b/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/index.js new file mode 100644 index 00000000..8dfea4a2 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/index.js @@ -0,0 +1,21 @@ +/** + * analysim_notifier/index.js + * + * Notifies the parent AnalySim window when JupyterLite is fully ready. + * Uses app.restored which waits for the complete UI restore, making it + * more reliable than app.started for detecting notebook readiness. + */ + +const plugin = { + id: 'analysim-notifier:plugin', + autoStart: true, + requires: [], + activate: (app) => { + app.restored.then(() => { + console.log('AnalySim: notebook environment ready, notifying parent window'); + window.parent.postMessage('jupyterlite-load', '*'); + }); + } +}; + +export default plugin; diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/package.json b/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/package.json new file mode 100644 index 00000000..5b385af2 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/assets/jupyter/analysim_notifier/package.json @@ -0,0 +1,10 @@ +{ + "name": "analysim-notifier", + "version": "0.1.0", + "description": "Notifies the parent AnalySim window when JupyterLite has finished loading", + "main": "index.js", + "jupyterlab": { + "extension": true, + "outputDir": "." + } +} diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/index.html b/src/Analysim.Web/ClientApp/src/assets/jupyter/index.html deleted file mode 100644 index 3e3cde58..00000000 --- a/src/Analysim.Web/ClientApp/src/assets/jupyter/index.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - JupyterLite - - - - - - - - - - - - - - diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter-lite.json b/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter-lite.json new file mode 100644 index 00000000..633f7fee --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter-lite.json @@ -0,0 +1,10 @@ +{ + "jupyter-lite-schema-version": 0, + "jupyter-config-data": { + "appName": "AnalySim Notebook", + "exposeAppInBrowser": false, + "disabledExtensions": [ + "@jupyterlab/application-extension:logo" + ] + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter_lite_config.json b/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter_lite_config.json new file mode 100644 index 00000000..30d835ea --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/assets/jupyter/jupyter_lite_config.json @@ -0,0 +1,9 @@ +{ + "LiteBuildConfig": { + "output_dir": "dist", + "lite_dir": ".", + "contents": [ + "notebooks" + ] + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/notebooks/.gitkeep b/src/Analysim.Web/ClientApp/src/assets/jupyter/notebooks/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/Analysim.Web/ClientApp/src/assets/jupyter/overrides.json b/src/Analysim.Web/ClientApp/src/assets/jupyter/overrides.json new file mode 100644 index 00000000..9f23a390 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/assets/jupyter/overrides.json @@ -0,0 +1,5 @@ +{ + "@jupyterlab/apputils-extension:themes": { + "theme": "JupyterLab Light" + } +} \ No newline at end of file