Skip to content

Commit 5f00c54

Browse files
committed
feat(notes): add jupyter notebooks support
1 parent 5c8fedc commit 5f00c54

File tree

11 files changed

+666
-18
lines changed

11 files changed

+666
-18
lines changed

apps/codever-api/src/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ setUpLogging();
7676

7777
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); //swagger docs are not protected
7878

79-
app.use(bodyParser.json());
79+
app.use(bodyParser.json({ limit: '6mb' })); // raised from default 100kb to support Jupyter notebook uploads
8080
app.use(bodyParser.urlencoded({ extended: false }));
8181

8282
app.set('trust proxy', 'loopback');

apps/codever-api/src/model/note.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const noteSchema = new Schema(
66
title: { type: String, required: true },
77
type: { type: String, required: true, default: 'note' },
88
content: String,
9+
// 'markdown' (default) or 'notebook' — determines how content is rendered
10+
contentType: { type: String, default: 'markdown' },
11+
// Raw .ipynb JSON stored here for notebook notes; not included in the text search index
12+
notebookContent: { type: String, select: true },
913
reference: String,
1014
initiator: {type:String, select: false},
1115
tags: [String],

apps/codever-api/src/routes/users/notes/note-input.validator.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,28 @@ let validateNoteInput = function (userId, note) {
1515
validationErrorMessages.push(NoteValidationErrorMessages.MISSING_TITLE);
1616
}
1717

18-
if (!note.content) {
19-
validationErrorMessages.push(NoteValidationErrorMessages.MISSING_CONTENT);
18+
// For notebook notes, content holds the extracted searchable text;
19+
// the raw .ipynb JSON is in notebookContent
20+
if (note.contentType === 'notebook') {
21+
if (!note.notebookContent) {
22+
validationErrorMessages.push(
23+
NoteValidationErrorMessages.MISSING_NOTEBOOK_CONTENT
24+
);
25+
}
26+
if (
27+
note.notebookContent &&
28+
note.notebookContent.length >
29+
NoteValidationRules.MAX_NUMBER_OF_CHARS_FOR_NOTEBOOK_CONTENT
30+
) {
31+
validationErrorMessages.push(
32+
NoteValidationErrorMessages.NOTEBOOK_CONTENT_TOO_LONG
33+
);
34+
}
35+
} else {
36+
// Standard markdown note — content is required
37+
if (!note.content) {
38+
validationErrorMessages.push(NoteValidationErrorMessages.MISSING_CONTENT);
39+
}
2040
}
2141

2242
if (note.content) {
@@ -38,7 +58,9 @@ let validateNoteInput = function (userId, note) {
3858
};
3959

4060
const NoteValidationRules = {
41-
MAX_NUMBER_OF_CHARS_FOR_CONTENT: 10000,
61+
MAX_NUMBER_OF_CHARS_FOR_CONTENT: 30_000,
62+
// Notebook raw JSON can be up to 5 MB (well within MongoDB's 16 MB BSON limit)
63+
MAX_NUMBER_OF_CHARS_FOR_NOTEBOOK_CONTENT: 5_000_000,
4264
MAX_NUMBER_OF_TAGS: 8,
4365
};
4466

@@ -50,6 +72,9 @@ const NoteValidationErrorMessages = {
5072
MISSING_TITLE: 'Missing required attribute - title',
5173
MISSING_CONTENT: 'Missing required attribute - content',
5274
CONTENT_TOO_LONG: `The content is too long. Only ${NoteValidationRules.MAX_NUMBER_OF_CHARS_FOR_CONTENT} allowed`,
75+
MISSING_NOTEBOOK_CONTENT:
76+
'Missing required attribute - notebookContent for notebook notes',
77+
NOTEBOOK_CONTENT_TOO_LONG: `The notebook content is too long. Only ${NoteValidationRules.MAX_NUMBER_OF_CHARS_FOR_NOTEBOOK_CONTENT} characters allowed`,
5378
};
5479

5580
module.exports = {

apps/codever-ui/src/app/core/model/note.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export interface Note {
66
reference?: string;
77
initiator?: string;
88
content: string;
9+
// 'markdown' (default) or 'notebook' — determines how content is rendered
10+
contentType?: 'markdown' | 'notebook';
11+
// Raw .ipynb JSON for notebook notes; content holds extracted searchable text
12+
notebookContent?: string;
913
color: string;
1014
tags: string[];
1115
createdAt?: Date;

apps/codever-ui/src/app/my-notes/save-note-form/note-editor.component.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,55 @@
7878
><span class="ml-1">Markdown is supported</span></a
7979
></label
8080
>
81+
82+
<!-- Notebook upload: file input + indicator when a notebook is loaded -->
83+
<div class="mb-2">
84+
<div *ngIf="!isNotebookMode">
85+
<label class="btn btn-sm btn-outline-secondary">
86+
<i class="fas fa-file-upload"></i> Upload Jupyter Notebook (.ipynb)
87+
<input
88+
type="file"
89+
accept=".ipynb"
90+
(change)="onNotebookFileSelected($event)"
91+
style="display: none"
92+
/>
93+
</label>
94+
</div>
95+
<div *ngIf="isNotebookMode" class="alert alert-info d-flex align-items-center py-2 mb-2">
96+
<i class="fas fa-book mr-2"></i>
97+
<span>Notebook loaded: <strong>{{ notebookFileName }}</strong></span>
98+
<button
99+
type="button"
100+
class="btn btn-sm btn-outline-danger ml-auto"
101+
(click)="removeNotebook()"
102+
title="Remove notebook and switch back to markdown"
103+
>
104+
<i class="fas fa-times"></i> Remove
105+
</button>
106+
</div>
107+
</div>
108+
109+
<!-- Markdown textarea: hidden when a notebook is loaded -->
81110
<textarea
111+
*ngIf="!isNotebookMode"
82112
class="form-control"
83113
id="content"
84114
formControlName="content"
85115
placeholder="Note text (searchable)"
86116
style="height: 300px"
87117
>
88118
</textarea>
119+
<!-- When notebook is loaded, show the extracted searchable text as readonly preview -->
120+
<textarea
121+
*ngIf="isNotebookMode"
122+
class="form-control"
123+
id="content"
124+
formControlName="content"
125+
placeholder="Extracted searchable text from notebook"
126+
style="height: 120px"
127+
readonly
128+
>
129+
</textarea>
89130
<div class="description-chars-counter">
90131
{{ content.value ? content.value.length : 0 }} / {{maxNumberOfCharacters}}
91132
</div>

apps/codever-ui/src/app/my-notes/save-note-form/note-editor.component.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.ipynb$/, '');
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()...');
Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
11

22

33
<div #noteContentDiv appMarkedImageWidth appCopyCodeButton>
4-
<div *ngIf="partOfList && viewHeight > maxNoteHeightInList && !isFullScreen; else wholeText">
5-
<div [ngClass]="{ more_text: showMoreText, less_text_note: !showMoreText }">
6-
<p [innerHtml]="note.content | md2html"></p>
4+
<!-- Notebook rendering: use the dedicated renderer for .ipynb content -->
5+
<div *ngIf="note.contentType === 'notebook'; else markdownBlock">
6+
<div *ngIf="partOfList && viewHeight > maxNoteHeightInList && !isFullScreen; else wholeNotebook">
7+
<div [ngClass]="{ more_text: showMoreText, less_text_note: !showMoreText }">
8+
<app-notebook-renderer [ipynbJson]="note.notebookContent"></app-notebook-renderer>
9+
</div>
10+
<button
11+
(click)="showMoreText = !showMoreText"
12+
[ngClass]="{
13+
show_less_button: showMoreText,
14+
show_more_button: !showMoreText
15+
}"
16+
class="toggle-show-more-button"
17+
[title]="showMoreText ? 'Show less' : 'Show more'"
18+
></button>
719
</div>
8-
<button
9-
(click)="showMoreText = !showMoreText"
10-
[ngClass]="{
11-
show_less_button: showMoreText,
12-
show_more_button: !showMoreText
13-
}"
14-
class="toggle-show-more-button"
15-
title="Show more"
16-
></button>
20+
<ng-template #wholeNotebook>
21+
<app-notebook-renderer [ipynbJson]="note.notebookContent"></app-notebook-renderer>
22+
</ng-template>
1723
</div>
18-
<ng-template #wholeText>
19-
<p [innerHtml]="note.content | md2html"></p>
24+
25+
<!-- Markdown rendering (default): same as before -->
26+
<ng-template #markdownBlock>
27+
<div *ngIf="partOfList && viewHeight > maxNoteHeightInList && !isFullScreen; else wholeText">
28+
<div [ngClass]="{ more_text: showMoreText, less_text_note: !showMoreText }">
29+
<p [innerHtml]="note.content | md2html"></p>
30+
</div>
31+
<button
32+
(click)="showMoreText = !showMoreText"
33+
[ngClass]="{
34+
show_less_button: showMoreText,
35+
show_more_button: !showMoreText
36+
}"
37+
class="toggle-show-more-button"
38+
[title]="showMoreText ? 'Show less' : 'Show more'"
39+
></button>
40+
</div>
41+
<ng-template #wholeText>
42+
<p [innerHtml]="note.content | md2html"></p>
43+
</ng-template>
2044
</ng-template>
2145
</div>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!-- Notebook renderer: iterates over parsed cells and renders them by type -->
2+
<div class="notebook-container" appCopyCodeButton>
3+
<div *ngFor="let cell of cells; let i = index" class="notebook-cell"
4+
[ngClass]="{
5+
'notebook-cell-markdown': cell.type === 'markdown',
6+
'notebook-cell-code': cell.type === 'code',
7+
'notebook-cell-raw': cell.type === 'raw',
8+
'notebook-cell-error': cell.type === 'error'
9+
}">
10+
<!-- Code cell: show In [n] label + highlighted source + outputs -->
11+
<div *ngIf="cell.type === 'code'" class="notebook-code-cell">
12+
<div class="notebook-input">
13+
<span class="notebook-prompt notebook-prompt-in">
14+
In [{{ cell.executionCount ?? ' ' }}]:
15+
</span>
16+
<div class="notebook-source" [innerHTML]="cell.sourceHtml"></div>
17+
</div>
18+
<!-- Outputs for code cells -->
19+
<div *ngFor="let output of cell.outputs" class="notebook-output">
20+
<span *ngIf="output.type !== 'error' && output.type !== 'stderr'"
21+
class="notebook-prompt notebook-prompt-out">
22+
Out:
23+
</span>
24+
<span *ngIf="output.type === 'error' || output.type === 'stderr'"
25+
class="notebook-prompt notebook-prompt-err">
26+
</span>
27+
<div class="notebook-output-content" [innerHTML]="output.html"></div>
28+
</div>
29+
</div>
30+
<!-- Markdown cell: rendered HTML -->
31+
<div *ngIf="cell.type === 'markdown'" class="notebook-markdown-cell"
32+
[innerHTML]="cell.sourceHtml">
33+
</div>
34+
<!-- Raw / error cell: preformatted text -->
35+
<div *ngIf="cell.type === 'raw' || cell.type === 'error'"
36+
class="notebook-raw-cell"
37+
[innerHTML]="cell.sourceHtml">
38+
</div>
39+
</div>
40+
</div>

0 commit comments

Comments
 (0)