Skip to content

Commit bc8f218

Browse files
committed
Merge branch 'feat/test'
2 parents a587476 + 5f00c54 commit bc8f218

File tree

14 files changed

+777
-19
lines changed

14 files changed

+777
-19
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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { AfterViewChecked, Directive, ElementRef } from '@angular/core';
2+
3+
@Directive({
4+
selector: '[appCopyCodeButton]',
5+
})
6+
export class CopyCodeButtonDirective implements AfterViewChecked {
7+
private static stylesInjected = false;
8+
9+
constructor(private el: ElementRef) {
10+
CopyCodeButtonDirective.injectStyles();
11+
}
12+
13+
ngAfterViewChecked(): void {
14+
const preElements = this.el.nativeElement.querySelectorAll('pre');
15+
preElements.forEach((pre: HTMLElement) => {
16+
if (pre.getAttribute('data-copy-btn-added') === 'true') {
17+
return;
18+
}
19+
20+
pre.setAttribute('data-copy-btn-added', 'true');
21+
pre.style.position = 'relative';
22+
23+
const button = document.createElement('button');
24+
button.className = 'copy-code-btn';
25+
button.title = 'Copy code';
26+
button.innerHTML = '<i class="far fa-copy"></i>';
27+
28+
button.addEventListener('click', () => {
29+
const code = pre.querySelector('code');
30+
const text = code ? code.textContent : pre.textContent;
31+
navigator.clipboard.writeText(text || '').then(() => {
32+
button.innerHTML = '<i class="fas fa-check"></i>';
33+
setTimeout(() => {
34+
button.innerHTML = '<i class="far fa-copy"></i>';
35+
}, 1500);
36+
});
37+
});
38+
39+
pre.appendChild(button);
40+
});
41+
}
42+
43+
private static injectStyles(): void {
44+
if (CopyCodeButtonDirective.stylesInjected) {
45+
return;
46+
}
47+
CopyCodeButtonDirective.stylesInjected = true;
48+
49+
const style = document.createElement('style');
50+
style.textContent = `
51+
pre[data-copy-btn-added="true"] {
52+
position: relative;
53+
}
54+
55+
.copy-code-btn {
56+
position: absolute;
57+
top: 4px;
58+
right: 4px;
59+
display: none;
60+
align-items: center;
61+
justify-content: center;
62+
width: 32px;
63+
height: 32px;
64+
padding: 0;
65+
border: 1px solid rgba(0, 0, 0, 0.15);
66+
border-radius: 4px;
67+
background: rgba(255, 255, 255, 0.9);
68+
color: #555;
69+
font-size: 14px;
70+
cursor: pointer;
71+
z-index: 10;
72+
transition: background-color 0.15s ease, color 0.15s ease;
73+
}
74+
75+
.copy-code-btn:hover {
76+
background: #fff;
77+
color: #000;
78+
border-color: rgba(0, 0, 0, 0.3);
79+
}
80+
81+
pre[data-copy-btn-added="true"]:hover .copy-code-btn {
82+
display: flex;
83+
}
84+
`;
85+
document.head.appendChild(style);
86+
}
87+
}
88+

0 commit comments

Comments
 (0)