Merge pull request #1035 from ae-utbm/picture-upload

Picture upload from markdown editor
This commit is contained in:
Bartuccio Antoine 2025-04-10 11:41:54 +02:00 committed by GitHub
commit e96d224a8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 389 additions and 23 deletions

View File

@ -17,7 +17,16 @@ from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import Permission 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) admin.site.unregister(AuthGroup)
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
list_display = ("label", "operator", "operation_type", "date") list_display = ("label", "operator", "operation_type", "date")
search_fields = ("label", "date", "operation_type") search_fields = ("label", "date", "operation_type")
autocomplete_fields = ("operator",) 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")

View File

@ -1,23 +1,25 @@
from typing import Annotated from typing import Annotated, Any, Literal
import annotated_types import annotated_types
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse 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 import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
from core.models import Group, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema, GroupSchema,
MarkdownSchema, MarkdownSchema,
SithFileSchema, SithFileSchema,
UploadedFileSchema,
UploadedImage,
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
return HttpResponse(markdown(body.text), content_type="text/html") 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") @api_controller("/mailings")
class MailingListController(ControllerBase): class MailingListController(ControllerBase):
@route.get("", response=str) @route.get("", response=str)

View File

@ -828,6 +828,7 @@ Welcome to the wiki page!
"view_peoplepicturerelation", "view_peoplepicturerelation",
"add_peoplepicturerelation", "add_peoplepicturerelation",
"add_page", "add_page",
"add_quickuploadimage",
] ]
) )
) )

View 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,
),
),
],
),
]

View File

@ -17,7 +17,7 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # 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. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@ -32,6 +32,7 @@ from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager 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 import validators
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError 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.core.mail import send_mail
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q 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.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image from PIL import Image, ImageOps
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from club.models import Club from club.models import Club
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
return reverse("core:download", kwargs={"file_id": self.id}) 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): class LockError(Exception):
"""There was a lock error on the object.""" """There was a lock error on the object."""

View File

@ -12,7 +12,7 @@ from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo 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 from core.utils import is_image
@ -60,6 +60,18 @@ class UserProfileSchema(ModelSchema):
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id}) 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 SithFileSchema(ModelSchema):
class Meta: class Meta:
model = SithFile model = SithFile

View File

@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi"; import {
type UploadUploadImageErrors,
markdownRenderMarkdown,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => { const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({ const easymde = new EasyMDE({
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: 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 ![](url) instead of ![name](url)
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) => { previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */ /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => { return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}, 300)(plainText, preview); }, 300)(plainText, preview);
}, },
forceSync: true, // Avoid validation error on generic create view 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: [ toolbar: [
{ {
name: "heading-smaller", name: "heading-smaller",
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
className: "fa-regular fa-image", className: "fa-regular fa-image",
title: gettext("Insert image"), title: gettext("Insert image"),
}, },
{
name: "upload-image",
action: EasyMDE.drawUploadedImage,
className: "fa-solid fa-file-arrow-up",
title: gettext("Upload image"),
},
{ {
name: "table", name: "table",
action: EasyMDE.drawTable, action: EasyMDE.drawTable,

View File

@ -1,11 +1,12 @@
from io import BytesIO from io import BytesIO
from itertools import cycle from itertools import cycle
from pathlib import Path
from typing import Callable from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.core.cache import cache 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.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@ -14,7 +15,8 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user 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 sas.models import Picture
from sith import settings 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.view_groups.all()) == set(groups[:3])
assert set(file.edit_groups.all()) == set(groups[2:6]) 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]
)

View File

@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
def send_file( def send_file(
request: HttpRequest, request: HttpRequest,
file_id: int, file_id: int | str,
file_class: type[SithFile] = SithFile, file_class: type[SithFile] = SithFile,
file_attr: str = "file", file_attr: str = "file",
) -> HttpResponse: ) -> HttpResponse:
@ -97,7 +97,7 @@ def send_file(
deal with it. deal with it.
In debug mode, the server will directly send the file. 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): if not can_view(f, request.user) and not is_logged_in_counter(request):
raise PermissionDenied raise PermissionDenied
name = getattr(f, file_attr).name name = getattr(f, file_attr).name

View File

@ -224,7 +224,7 @@ server {
location /static/; location /static/;
root /repertoire/du/projet; root /repertoire/du/projet;
} }
location ~ ^/data/(products|com|club_logos)/ { location ~ ^/data/(products|com|club_logos|upload)/ {
root /repertoire/du/projet; root /repertoire/du/projet;
} }
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ { location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {

View File

@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
msgid "You must provide a file" msgid "You must provide a file"
msgstr "Vous devez fournir un fichier" msgstr "Vous devez fournir un fichier"
#: core/models.py
msgid "width"
msgstr "largeur"
#: core/models.py
msgid "height"
msgstr "hauteur"
#: core/models.py #: core/models.py
msgid "page unix name" msgid "page unix name"
msgstr "nom unix de la page" msgstr "nom unix de la page"

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.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" msgid "No results found"
msgstr "Aucun résultat trouvé" 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 #: core/static/bundled/core/components/easymde-index.ts
msgid "Heading" msgid "Heading"
msgstr "Titre" msgstr "Titre"
@ -115,6 +149,10 @@ msgstr "Insérer lien"
msgid "Insert image" msgid "Insert image"
msgstr "Insérer 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 #: core/static/bundled/core/components/easymde-index.ts
msgid "Insert table" msgid "Insert table"
msgstr "Insérer tableau" msgstr "Insérer tableau"

12
package-lock.json generated
View File

@ -27,7 +27,7 @@
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
@ -3663,14 +3663,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/easymde": { "node_modules/easymde": {
"version": "2.18.0", "version": "2.19.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz", "resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==", "integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/codemirror": "^5.60.4", "@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7", "@types/marked": "^4.0.7",
"codemirror": "^5.63.1", "codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2", "codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0" "marked": "^4.1.0"
} }

View File

@ -54,7 +54,7 @@
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",