mirror of
https://github.com/ae-utbm/sith.git
synced 2025-04-15 18:40:23 +00:00
Merge pull request #1035 from ae-utbm/picture-upload
Picture upload from markdown editor
This commit is contained in:
commit
e96d224a8d
@ -17,7 +17,16 @@ from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as AuthGroup
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
|
||||
from core.models import (
|
||||
BanGroup,
|
||||
Group,
|
||||
OperationLog,
|
||||
Page,
|
||||
QuickUploadImage,
|
||||
SithFile,
|
||||
User,
|
||||
UserBan,
|
||||
)
|
||||
|
||||
admin.site.unregister(AuthGroup)
|
||||
|
||||
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "operator", "operation_type", "date")
|
||||
search_fields = ("label", "date", "operation_type")
|
||||
autocomplete_fields = ("operator",)
|
||||
|
||||
|
||||
@admin.register(QuickUploadImage)
|
||||
class QuickUploadImageAdmin(admin.ModelAdmin):
|
||||
list_display = ("uuid", "uploader", "created_at", "name")
|
||||
search_fields = ("uuid", "uploader", "name")
|
||||
autocomplete_fields = ("uploader",)
|
||||
readonly_fields = ("width", "height", "size")
|
||||
|
29
core/api.py
29
core/api.py
@ -1,23 +1,25 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import annotated_types
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponse
|
||||
from ninja import Query
|
||||
from ninja import File, Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from club.models import Mailing
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView
|
||||
from core.models import Group, SithFile, User
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
GroupSchema,
|
||||
MarkdownSchema,
|
||||
SithFileSchema,
|
||||
UploadedFileSchema,
|
||||
UploadedImage,
|
||||
UserFamilySchema,
|
||||
UserFilterSchema,
|
||||
UserProfileSchema,
|
||||
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
|
||||
return HttpResponse(markdown(body.text), content_type="text/html")
|
||||
|
||||
|
||||
@api_controller("/upload")
|
||||
class UploadController(ControllerBase):
|
||||
@route.post(
|
||||
"/image",
|
||||
response={
|
||||
200: UploadedFileSchema,
|
||||
422: dict[Literal["detail"], list[dict[str, Any]]],
|
||||
403: dict[Literal["detail"], str],
|
||||
},
|
||||
permissions=[HasPerm("core.add_quickuploadimage")],
|
||||
url_name="quick_upload_image",
|
||||
)
|
||||
def upload_image(self, file: File[UploadedImage]):
|
||||
image = QuickUploadImage.create_from_uploaded(
|
||||
file, uploader=self.context.request.user
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
@api_controller("/mailings")
|
||||
class MailingListController(ControllerBase):
|
||||
@route.get("", response=str)
|
||||
|
@ -828,6 +828,7 @@ Welcome to the wiki page!
|
||||
"view_peoplepicturerelation",
|
||||
"add_peoplepicturerelation",
|
||||
"add_page",
|
||||
"add_quickuploadimage",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
56
core/migrations/0045_quickuploadimage.py
Normal file
56
core/migrations/0045_quickuploadimage.py
Normal file
@ -0,0 +1,56 @@
|
||||
# Generated by Django 4.2.20 on 2025-04-10 09:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0044_alter_userban_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="QuickUploadImage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(db_index=True, unique=True)),
|
||||
("name", models.CharField(max_length=100)),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
height_field="height",
|
||||
unique=True,
|
||||
upload_to="upload/%Y/%m/%d",
|
||||
width_field="width",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||
),
|
||||
("width", models.PositiveIntegerField(verbose_name="width")),
|
||||
("height", models.PositiveIntegerField(verbose_name="height")),
|
||||
("size", models.PositiveIntegerField(verbose_name="size")),
|
||||
(
|
||||
"uploader",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="quick_uploads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@ -17,7 +17,7 @@
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
@ -32,6 +32,7 @@ from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Self
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
@ -41,6 +42,8 @@ from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core import validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.files import File
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
@ -51,9 +54,10 @@ from django.utils.html import escape
|
||||
from django.utils.timezone import localdate, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from pydantic import NonNegativeInt
|
||||
|
||||
from club.models import Club
|
||||
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
|
||||
return reverse("core:download", kwargs={"file_id": self.id})
|
||||
|
||||
|
||||
class QuickUploadImage(models.Model):
|
||||
"""Images uploaded by user outside of the SithFile mechanism"""
|
||||
|
||||
IMAGE_NAME_SIZE = 100
|
||||
MAX_IMAGE_SIZE = 600 # Maximum px on width / length
|
||||
|
||||
uuid = models.UUIDField(unique=True, db_index=True)
|
||||
name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False)
|
||||
image = models.ImageField(
|
||||
upload_to="upload/%Y/%m/%d",
|
||||
width_field="width",
|
||||
height_field="height",
|
||||
unique=True,
|
||||
)
|
||||
uploader = models.ForeignKey(
|
||||
"User",
|
||||
related_name="quick_uploads",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
width = models.PositiveIntegerField(_("width"))
|
||||
height = models.PositiveIntegerField(_("height"))
|
||||
size = models.PositiveIntegerField(_("size"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.image.path)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.image.url
|
||||
|
||||
@classmethod
|
||||
def create_from_uploaded(
|
||||
cls, image: UploadedFile, uploader: User | None = None
|
||||
) -> Self:
|
||||
def convert_image(file: UploadedFile) -> ContentFile:
|
||||
content = BytesIO()
|
||||
image = Image.open(BytesIO(file.read()))
|
||||
if image.width > cls.MAX_IMAGE_SIZE or image.height > cls.MAX_IMAGE_SIZE:
|
||||
image = ImageOps.contain(image, (600, 600))
|
||||
image.save(fp=content, format="webp", optimize=True)
|
||||
return ContentFile(content.getvalue())
|
||||
|
||||
identifier = str(uuid4())
|
||||
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
|
||||
file = File(convert_image(image), name=f"{identifier}.webp")
|
||||
width, height = Image.open(file).size
|
||||
|
||||
return cls.objects.create(
|
||||
uuid=identifier,
|
||||
name=name,
|
||||
image=file,
|
||||
uploader=uploader,
|
||||
size=file.size,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.image.delete(save=False)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class LockError(Exception):
|
||||
"""There was a lock error on the object."""
|
||||
|
||||
|
@ -12,7 +12,7 @@ from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.models import Group, SithFile, User
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import is_image
|
||||
|
||||
|
||||
@ -60,6 +60,18 @@ class UserProfileSchema(ModelSchema):
|
||||
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
||||
|
||||
|
||||
class UploadedFileSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = QuickUploadImage
|
||||
fields = ["uuid", "name", "width", "height", "size"]
|
||||
|
||||
href: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_href(obj: QuickUploadImage) -> str:
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class SithFileSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = SithFile
|
||||
|
@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
|
||||
import type CodeMirror from "codemirror";
|
||||
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
||||
import EasyMDE from "easymde";
|
||||
import { markdownRenderMarkdown } from "#openapi";
|
||||
import {
|
||||
type UploadUploadImageErrors,
|
||||
markdownRenderMarkdown,
|
||||
uploadUploadImage,
|
||||
} from "#openapi";
|
||||
|
||||
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
new EasyMDE({
|
||||
const easymde = new EasyMDE({
|
||||
element: textarea,
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
uploadImage: true,
|
||||
imagePathAbsolute: false,
|
||||
imageUploadFunction: async (file, onSuccess, onError) => {
|
||||
const response = await uploadUploadImage({
|
||||
body: {
|
||||
file: file,
|
||||
},
|
||||
});
|
||||
if (!response.response.ok) {
|
||||
if (response.response.status === 422) {
|
||||
onError(
|
||||
(response.error as UploadUploadImageErrors[422]).detail
|
||||
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
|
||||
.join(" ; "),
|
||||
);
|
||||
} else if (response.response.status === 403) {
|
||||
onError(gettext("You are not authorized to use this feature"));
|
||||
} else {
|
||||
onError(gettext("Could not upload image"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSuccess(response.data.href);
|
||||
// Workaround function to add an image name to uploaded image
|
||||
// Without this, you get  instead of 
|
||||
let cursor = easymde.codemirror.getCursor();
|
||||
|
||||
easymde.codemirror.setSelection({
|
||||
line: cursor.line,
|
||||
ch: cursor.ch - response.data.href.length - 3,
|
||||
});
|
||||
easymde.codemirror.replaceSelection(response.data.name);
|
||||
|
||||
// Move cursor at the end of the url and add a new line
|
||||
cursor = easymde.codemirror.getCursor();
|
||||
easymde.codemirror.setSelection({
|
||||
line: cursor.line,
|
||||
ch: cursor.ch + response.data.href.length + 3,
|
||||
});
|
||||
easymde.codemirror.replaceSelection("\n");
|
||||
},
|
||||
previewRender: (plainText: string, preview: MarkdownInput) => {
|
||||
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
}, 300)(plainText, preview);
|
||||
},
|
||||
forceSync: true, // Avoid validation error on generic create view
|
||||
imageTexts: {
|
||||
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
|
||||
sbOnDragEnter: gettext("Drop image to upload it."),
|
||||
sbOnDrop: gettext("Uploading image #images_names# …"),
|
||||
sbProgress: gettext("Uploading #file_name#: #progress#%"),
|
||||
sbOnUploaded: gettext("Uploaded #image_name#"),
|
||||
sizeUnits: gettext(" B, KB, MB"),
|
||||
},
|
||||
toolbar: [
|
||||
{
|
||||
name: "heading-smaller",
|
||||
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
className: "fa-regular fa-image",
|
||||
title: gettext("Insert image"),
|
||||
},
|
||||
{
|
||||
name: "upload-image",
|
||||
action: EasyMDE.drawUploadedImage,
|
||||
className: "fa-solid fa-file-arrow-up",
|
||||
title: gettext("Upload image"),
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
action: EasyMDE.drawTable,
|
||||
|
@ -1,11 +1,12 @@
|
||||
from io import BytesIO
|
||||
from itertools import cycle
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
@ -14,7 +15,8 @@ from PIL import Image
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||
from core.models import Group, SithFile, User
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Picture
|
||||
from sith import settings
|
||||
|
||||
@ -256,3 +258,89 @@ def test_apply_rights_recursively():
|
||||
):
|
||||
assert set(file.view_groups.all()) == set(groups[:3])
|
||||
assert set(file.edit_groups.all()) == set(groups[2:6])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_receipe", "file", "expected_status"),
|
||||
[
|
||||
(
|
||||
lambda: None,
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
403,
|
||||
),
|
||||
(
|
||||
lambda: baker.make(User),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
403,
|
||||
),
|
||||
(
|
||||
lambda: subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
|
||||
content=RED_PIXEL_PNG,
|
||||
content_type="image/jpg",
|
||||
),
|
||||
200,
|
||||
), # very long file name
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=b"invalid", content_type="image/jpg"
|
||||
),
|
||||
422,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
|
||||
),
|
||||
200, # PIL can guess
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
|
||||
422,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_quick_upload_image(
|
||||
client: Client,
|
||||
user_receipe: Callable[[], User | None],
|
||||
file: UploadedFile | None,
|
||||
expected_status: int,
|
||||
):
|
||||
if (user := user_receipe()) is not None:
|
||||
client.force_login(user)
|
||||
resp = client.post(
|
||||
reverse("api:quick_upload_image"), {"file": file} if file is not None else {}
|
||||
)
|
||||
|
||||
assert resp.status_code == expected_status
|
||||
|
||||
if expected_status != 200:
|
||||
return
|
||||
|
||||
parsed = resp.json()
|
||||
assert QuickUploadImage.objects.filter(uuid=parsed["uuid"]).exists()
|
||||
assert (
|
||||
parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1]
|
||||
)
|
||||
|
@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
|
||||
|
||||
def send_file(
|
||||
request: HttpRequest,
|
||||
file_id: int,
|
||||
file_id: int | str,
|
||||
file_class: type[SithFile] = SithFile,
|
||||
file_attr: str = "file",
|
||||
) -> HttpResponse:
|
||||
@ -97,7 +97,7 @@ def send_file(
|
||||
deal with it.
|
||||
In debug mode, the server will directly send the file.
|
||||
"""
|
||||
f = get_object_or_404(file_class, id=file_id)
|
||||
f = get_object_or_404(file_class, pk=file_id)
|
||||
if not can_view(f, request.user) and not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
name = getattr(f, file_attr).name
|
||||
|
@ -224,7 +224,7 @@ server {
|
||||
location /static/;
|
||||
root /repertoire/du/projet;
|
||||
}
|
||||
location ~ ^/data/(products|com|club_logos)/ {
|
||||
location ~ ^/data/(products|com|club_logos|upload)/ {
|
||||
root /repertoire/du/projet;
|
||||
}
|
||||
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
|
||||
|
@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
|
||||
msgid "You must provide a file"
|
||||
msgstr "Vous devez fournir un fichier"
|
||||
|
||||
#: core/models.py
|
||||
msgid "width"
|
||||
msgstr "largeur"
|
||||
|
||||
#: core/models.py
|
||||
msgid "height"
|
||||
msgstr "hauteur"
|
||||
|
||||
#: core/models.py
|
||||
msgid "page unix name"
|
||||
msgstr "nom unix de la page"
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-08 11:42+0200\n"
|
||||
"POT-Creation-Date: 2025-04-09 23:23+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -63,6 +63,40 @@ msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
msgid "No results found"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "You are not authorized to use this feature"
|
||||
msgstr "Vous n'êtes pas autorisé à utilisé cette fonctionalité"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Could not upload image"
|
||||
msgstr "L'image n'a pas pu être téléversée"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Attach files by drag and dropping or pasting from clipboard."
|
||||
msgstr ""
|
||||
"Ajoutez des fichiez en glissant déposant ou collant depuis votre presse "
|
||||
"papier."
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Drop image to upload it."
|
||||
msgstr "Glissez une image pour la téléverser."
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploading image #images_names# …"
|
||||
msgstr "Téléversement de l'image #images_names# …"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploading #file_name#: #progress#%"
|
||||
msgstr "Téléversement de #file_name#: #progress#%"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploaded #image_name#"
|
||||
msgstr "#image_name# téléversé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid " B, KB, MB"
|
||||
msgstr " B, KB, MB"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
@ -115,6 +149,10 @@ msgstr "Insérer lien"
|
||||
msgid "Insert image"
|
||||
msgstr "Insérer image"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Upload image"
|
||||
msgstr "Téléverser une image"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Insert table"
|
||||
msgstr "Insérer tableau"
|
||||
|
12
package-lock.json
generated
12
package-lock.json
generated
@ -27,7 +27,7 @@
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
"cytoscape-klay": "^3.1.4",
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.18.0",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
@ -3663,14 +3663,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/easymde": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
|
||||
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
|
||||
"integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "^5.60.4",
|
||||
"@types/codemirror": "^5.60.10",
|
||||
"@types/marked": "^4.0.7",
|
||||
"codemirror": "^5.63.1",
|
||||
"codemirror": "^5.65.15",
|
||||
"codemirror-spell-checker": "1.1.2",
|
||||
"marked": "^4.1.0"
|
||||
}
|
||||
|
@ -54,7 +54,7 @@
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
"cytoscape-klay": "^3.1.4",
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.18.0",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
|
Loading…
x
Reference in New Issue
Block a user