@@ -82,6 +82,14 @@ export class NoteEditorComponent implements OnInit, OnDestroy, OnChanges {
8282
8383 readonly maxNumberOfCharacters = 30000 ;
8484
85+ // --- Notebook upload state ---
86+ /** Whether a notebook file has been loaded (switches UI from textarea to notebook indicator) */
87+ isNotebookMode = false ;
88+ /** Name of the uploaded .ipynb file (shown in the UI) */
89+ notebookFileName = '' ;
90+ /** Raw .ipynb JSON string to be stored in notebookContent */
91+ notebookRawJson = '' ;
92+
8593 @Input ( )
8694 title ; // value of "title" query parameter if present
8795
@@ -191,6 +199,13 @@ export class NoteEditorComponent implements OnInit, OnDestroy, OnChanges {
191199 formTags . push ( this . formBuilder . control ( this . note . tags [ i ] ) ) ;
192200 }
193201
202+ // Restore notebook mode if editing/cloning a notebook note
203+ if ( this . note . contentType === 'notebook' && this . note . notebookContent ) {
204+ this . isNotebookMode = true ;
205+ this . notebookRawJson = this . note . notebookContent ;
206+ this . notebookFileName = this . note . title + '.ipynb' ;
207+ }
208+
194209 this . tagsControl . setValue ( null ) ;
195210 this . tags . markAsDirty ( ) ;
196211 }
@@ -250,6 +265,14 @@ export class NoteEditorComponent implements OnInit, OnDestroy, OnChanges {
250265 }
251266
252267 saveNote ( note : Note ) {
268+ // Attach notebook fields before saving
269+ if ( this . isNotebookMode ) {
270+ note . contentType = 'notebook' ;
271+ note . notebookContent = this . notebookRawJson ;
272+ } else {
273+ note . contentType = 'markdown' ;
274+ }
275+
253276 if ( this . isEditMode ) {
254277 this . updateNote ( note ) ;
255278 } else if ( this . cloneNote ) {
@@ -324,6 +347,102 @@ export class NoteEditorComponent implements OnInit, OnDestroy, OnChanges {
324347 return this . noteForm . get ( 'content' ) ;
325348 }
326349
350+ // ---------------------------------------------------------------------------
351+ // Notebook (.ipynb) file upload handling
352+ // ---------------------------------------------------------------------------
353+
354+ /**
355+ * Called when the user selects a .ipynb file.
356+ * Reads the file, validates it's a valid notebook JSON, extracts searchable
357+ * text into the 'content' form field, and stores the raw JSON for notebookContent.
358+ */
359+ onNotebookFileSelected ( event : Event ) : void {
360+ const input = event . target as HTMLInputElement ;
361+ if ( ! input . files || input . files . length === 0 ) {
362+ return ;
363+ }
364+
365+ const file = input . files [ 0 ] ;
366+ if ( ! file . name . endsWith ( '.ipynb' ) ) {
367+ alert ( 'Please select a .ipynb file' ) ;
368+ return ;
369+ }
370+
371+ // 5 MB limit matching backend MAX_NUMBER_OF_CHARS_FOR_NOTEBOOK_CONTENT
372+ if ( file . size > 5_000_000 ) {
373+ alert ( 'Notebook file is too large. Maximum 5 MB allowed.' ) ;
374+ return ;
375+ }
376+
377+ const reader = new FileReader ( ) ;
378+ reader . onload = ( ) => {
379+ try {
380+ const json = reader . result as string ;
381+ const nb = JSON . parse ( json ) ;
382+
383+ // Basic validation: must have a cells array (nbformat v4)
384+ if ( ! nb . cells || ! Array . isArray ( nb . cells ) ) {
385+ alert ( 'Invalid notebook file: missing "cells" array.' ) ;
386+ return ;
387+ }
388+
389+ this . notebookRawJson = json ;
390+ this . notebookFileName = file . name ;
391+ this . isNotebookMode = true ;
392+
393+ // Extract readable text from markdown + code cells for full-text search
394+ const searchableText = this . extractSearchableText ( nb ) ;
395+ this . noteForm . patchValue ( { content : searchableText } ) ;
396+
397+ // Clear the content size validator — extracted text from notebooks can exceed
398+ // the normal 30k char limit; the backend validates notebookContent separately
399+ this . noteForm . get ( 'content' ) . clearValidators ( ) ;
400+ this . noteForm . get ( 'content' ) . updateValueAndValidity ( ) ;
401+
402+ // Auto-fill the title from the filename if empty
403+ if ( ! this . noteForm . get ( 'title' ) . value ) {
404+ const titleFromFile = file . name . replace ( / \. i p y n b $ / , '' ) ;
405+ this . noteForm . patchValue ( { title : titleFromFile } ) ;
406+ }
407+ } catch ( e ) {
408+ alert ( 'Failed to parse notebook JSON: ' + e . message ) ;
409+ }
410+ } ;
411+ reader . readAsText ( file ) ;
412+ }
413+
414+ /** Remove the uploaded notebook and switch back to markdown mode */
415+ removeNotebook ( ) : void {
416+ this . isNotebookMode = false ;
417+ this . notebookFileName = '' ;
418+ this . notebookRawJson = '' ;
419+ this . noteForm . patchValue ( { content : '' } ) ;
420+
421+ // Restore the default content size validator for markdown notes
422+ this . noteForm
423+ . get ( 'content' )
424+ . setValidators ( textSizeValidator ( this . maxNumberOfCharacters , 30000 ) ) ;
425+ this . noteForm . get ( 'content' ) . updateValueAndValidity ( ) ;
426+ }
427+
428+ /**
429+ * Extract readable text from notebook cells for the full-text search index.
430+ * Concatenates markdown cell text and code cell source, separated by newlines.
431+ * This goes into the 'content' field (indexed by MongoDB), NOT the raw JSON.
432+ */
433+ private extractSearchableText ( nb : any ) : string {
434+ const parts : string [ ] = [ ] ;
435+ for ( const cell of nb . cells ) {
436+ const source = Array . isArray ( cell . source )
437+ ? cell . source . join ( '' )
438+ : cell . source || '' ;
439+ if ( cell . cell_type === 'markdown' || cell . cell_type === 'code' ) {
440+ parts . push ( source ) ;
441+ }
442+ }
443+ return parts . join ( '\n\n' ) ;
444+ }
445+
327446 cancelUpdate ( ) {
328447 this . _location . back ( ) ;
329448 console . log ( 'goBack()...' ) ;
0 commit comments