mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-13 17:45:24 +00:00
Enhancement: render document notes as markdown (#14626)
--------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
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())
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user