Enhancement: render document notes as markdown (#14626)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Johannes Hagerandshamoon authored and GitHub committed 2026-11-13 09:08:20 -08:00
1 parent bfd5628dcc
commit b63ea77543
13 files changed
+270 -30

No files matched your search

@@ -2,26 +2,47 @@
<div>
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
<div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
<div class="invalid-feedback" i18n>
Please enter a note.
</div>
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
@if (noteView() === NoteViewMode.Preview) {
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
@if (noteForm.get('newNote').value) {
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
} @else {
<span class="text-muted" i18n>Nothing to preview</span>
}
</section>
}
<div id="noteFeedback" class="invalid-feedback" i18n>
Please enter a note.
</div>
</div>
<div class="form-group mt-2 d-flex justify-content-end align-items-center">
<div class="form-group mt-2 d-flex justify-content-between align-items-center">
@if (networkActive()) {
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
}
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
</fieldset>
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
</div>
</form>
<hr>
@for (note of notes(); track note) {
@for (note of notes(); track note.id) {
<div class="card border mb-3">
<div class="card-body text-dark">
<p class="card-text">{{note.note}}</p>
@if (note.format === NoteFormat.Markdown) {
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
} @else {
<p class="card-text">{{note.note}}</p>
}
</div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(note)}} - {{ note.created | customDate}}</span>
<span>
{{displayName(note)}} - {{ note.created | customDate}}
</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
<pngx-bs-icon name="trash"></pngx-bs-icon>
<span class="visually-hidden" i18n>Delete note</span>
@@ -5,3 +5,42 @@
.card:hover .fade {
opacity: 1;
}
.note-preview {
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
}
.note-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
table {
margin-bottom: 1rem;
display: block;
overflow-x: auto;
white-space: nowrap;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
pre {
padding: 0.5rem;
background-color: var(--bs-tertiary-bg);
border-radius: var(--bs-border-radius);
}
blockquote {
padding-left: 0.75rem;
border-left: 0.25rem solid var(--bs-border-color);
color: var(--bs-secondary-color);
}
> :last-child {
margin-bottom: 0;
}
}
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { of, throwError } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
})
it('should render notes according to their format', () => {
component.notes.set([
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
{
id: 32,
note: '<script>alert(1)</script>',
format: NoteFormat.Markdown,
},
{ id: 33, note: '**x**', format: NoteFormat.Plain },
])
fixture.detectChanges()
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
'x'
)
expect(noteCards[1].query(By.css('script'))).toBeNull()
expect(noteCards[2].query(By.css('strong'))).toBeNull()
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
})
it('should toggle between edit and preview', () => {
const radios = fixture.debugElement.queryAll(
By.css('.btn-group input[type="radio"]')
)
radios[1].triggerEventHandler('change')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview')).nativeElement
.textContent
).toContain('Nothing to preview')
component.noteForm.get('newNote').setValue('**bold**')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview strong'))
).not.toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).toContain('d-none')
radios[0].triggerEventHandler('change')
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).not.toContain('d-none')
})
it('should return to edit mode after saving', () => {
component.documentId.set(12)
component.noteForm.get('newNote').setValue('Preview note')
fixture.debugElement
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
.triggerEventHandler('change')
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
component.addNote()
expect(component.noteView()).toBe('edit')
})
it('should handle note user display in all situations', () => {
expect(
component.displayName({
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
const addSpy = jest.spyOn(notesService, 'addNote')
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
const toastsSpy = jest.spyOn(toastService, 'showError')
const addButton = fixture.debugElement.query(By.css('button'))
const addButton = fixture.debugElement.query(By.css('.btn-primary'))
addButton.triggerEventHandler('click')
expect(addSpy).toHaveBeenCalledWith(12, note)
expect(toastsSpy).toHaveBeenCalled()
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
component.documentId.set(12)
component.notes.set(notes)
fixture.detectChanges()
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
const deleteButton = fixture.debugElement.query(
By.css('.card-footer button')
)
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
const toastsSpy = jest.spyOn(toastService, 'showError')
deleteSpy.mockReturnValueOnce(
@@ -14,15 +14,21 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
enum NoteViewMode {
Edit = 'edit',
Preview = 'preview',
}
@Component({
selector: 'pngx-document-notes',
templateUrl: './document-notes.component.html',
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
imports: [
IfPermissionsDirective,
CustomDatePipe,
MarkdownPipe,
FormsModule,
ReactiveFormsModule,
BootstrapIconComponent,
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
readonly notes = model<DocumentNote[]>([])
readonly addDisabled = input(false)
readonly networkActive = signal(false)
protected readonly NoteViewMode = NoteViewMode
protected readonly NoteFormat = NoteFormat
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
noteForm: FormGroup = new FormGroup({
newNote: new FormControl(''),
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
next: (result) => {
this.notes.set(result)
this.noteForm.get('newNote').reset()
this.noteView.set(NoteViewMode.Edit)
this.networkActive.set(false)
this.updated.emit(this.notes())
},
+6
View File
@@ -1,8 +1,14 @@
import { ObjectWithId } from './object-with-id'
import { User } from './user'
export enum NoteFormat {
Plain = 'plain',
Markdown = 'markdown',
}
export interface DocumentNote extends ObjectWithId {
created?: Date
note?: string
user?: User
format?: NoteFormat
}
@@ -1,6 +1,7 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { NoteFormat } from 'src/app/data/document-note'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { DocumentNotesService } from './document-notes.service'
@@ -50,6 +51,7 @@ describe(`Additional service tests for DocumentNotesService`, () => {
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
note: content,
format: NoteFormat.Markdown,
})
})
@@ -1,7 +1,7 @@
import { HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
addNote(id: number, note: string): Observable<DocumentNote[]> {
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
note: note,
format: NoteFormat.Markdown,
})
}
@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-11-03 05:57
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0029_alter_workflowrun_type_alter_workflowtrigger_type"),
]
operations = [
migrations.AddField(
model_name="note",
name="format",
field=models.CharField(
choices=[("plain", "Plain"), ("markdown", "Markdown")],
default="plain",
max_length=16,
verbose_name="format",
),
),
]
+11
View File
@@ -1006,12 +1006,23 @@ class PaperlessTask(ModelWithOwner):
class Note(SoftDeleteModel):
class Format(models.TextChoices):
PLAIN = ("plain", _("Plain"))
MARKDOWN = ("markdown", _("Markdown"))
note = models.TextField(
_("content"),
blank=True,
help_text=_("Note for the document"),
)
format = models.CharField(
max_length=16,
choices=Format,
default=Format.PLAIN,
verbose_name=_("format"),
)
created = models.DateTimeField(
_("created"),
default=timezone.now,
+10 -1
View File
@@ -971,10 +971,19 @@ class NotesSerializer(serializers.ModelSerializer[Note]):
class Meta:
model = Note
fields = ["id", "note", "created", "user"]
fields = ["id", "note", "format", "created", "user"]
ordering = ["-created"]
class NoteCreateSerializer(serializers.Serializer[dict[str, Any]]):
note = serializers.CharField()
format = serializers.ChoiceField(
choices=Note.Format.choices,
default=Note.Format.PLAIN,
required=False,
)
def _get_viewable_duplicates(
document: Document,
user: User | None,
+54
View File
@@ -3689,6 +3689,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
{
"id": note.id,
"note": note.note,
"format": Note.Format.PLAIN,
"user": {
"id": note.user.id,
"username": note.user.username,
@@ -3737,11 +3738,64 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
resp_data = resp_data[0]
self.assertEqual(resp_data["note"], "this is a posted note")
self.assertEqual(resp_data["format"], Note.Format.PLAIN)
doc = Document.objects.get(pk=doc.pk)
# modified was updated to today
self.assertEqual(doc.modified.day, timezone.now().day)
def test_create_markdown_note(self) -> None:
doc = DocumentFactory(
title="test",
content="this is a document which will have a markdown note",
)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "**markdown**", "format": Note.Format.MARKDOWN},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
note = Note.objects.get(document=doc)
self.assertEqual(note.format, Note.Format.MARKDOWN)
response = self.client.get(f"/api/documents/{doc.pk}/notes/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()[0]["format"], Note.Format.MARKDOWN)
def test_create_note_rejects_unsupported_format(self) -> None:
doc = DocumentFactory(
title="test",
content="this is a document which will have a note",
)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "unsupported", "format": "html"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("format", response.json())
self.assertFalse(Note.objects.filter(document=doc).exists())
def test_create_note_requires_note(self) -> None:
doc = DocumentFactory(
title="test",
content="this is a document which will have a note",
)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"format": Note.Format.MARKDOWN},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("note", response.json())
self.assertFalse(Note.objects.filter(document=doc).exists())
def test_create_note_only_saves_document_modified_field(self) -> None:
"""
GIVEN:
+16 -16
View File
@@ -207,6 +207,7 @@ from documents.serialisers import EditPdfDocumentsSerializer
from documents.serialisers import EmailSerializer
from documents.serialisers import MergeDocumentsAsVersionsSerializer
from documents.serialisers import MergeDocumentsSerializer
from documents.serialisers import NoteCreateSerializer
from documents.serialisers import NotesSerializer
from documents.serialisers import PostDocumentSerializer
from documents.serialisers import RemovePasswordDocumentsSerializer
@@ -833,12 +834,7 @@ class EmailDocumentDetailSchema(EmailSerializer):
notes=extend_schema(
description="View, add, or delete notes for the document",
methods=["GET", "POST", "DELETE"],
request=inline_serializer(
name="NoteCreateRequest",
fields={
"note": serializers.CharField(),
},
),
request=NoteCreateSerializer,
parameters=[
OpenApiParameter(
name="id",
@@ -1896,19 +1892,23 @@ class DocumentViewSet(
{"error": "Error retrieving notes, check logs for more detail."},
)
elif request.method == "POST":
try:
if currentUser is not None and not has_perms_owner_aware(
currentUser,
"change_document",
doc,
):
return HttpResponseForbidden(
"Insufficient permissions to create notes",
)
if currentUser is not None and not has_perms_owner_aware(
currentUser,
"change_document",
doc,
):
return HttpResponseForbidden(
"Insufficient permissions to create notes",
)
note_serializer = NoteCreateSerializer(data=request.data)
note_serializer.is_valid(raise_exception=True)
try:
c = Note.objects.create(
document=doc,
note=request.data["note"],
note=note_serializer.validated_data["note"],
format=note_serializer.validated_data["format"],
user=currentUser,
)
# If audit log is enabled make an entry in the log
+1
View File
@@ -228,6 +228,7 @@ SPECTACULAR_SETTINGS = {
"ENUM_NAME_OVERRIDES": {
"MatchingAlgorithm": "documents.models.MatchingModel.MATCHING_ALGORITHMS",
"BarcodeFormatEnum": "documents.models.DocumentBarcode.Format",
"NoteFormatEnum": "documents.models.Note.Format",
},
"SCHEMA_PATH_PREFIX_INSERT": FORCE_SCRIPT_NAME or "",
}