diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d73cefbe..4649914c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.11" - uses: pre-commit/action@v2.0.3 tests: @@ -25,10 +25,11 @@ jobs: max-parallel: 5 matrix: python-version: - - "3.6" - - "3.7" - "3.8" - "3.9" + - "3.10" + - "3.11" +# - "3.12" # Enable when we get rid of setuptools steps: - uses: actions/checkout@v2 @@ -57,7 +58,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.11" - uses: actions/download-artifact@v3 with: name: coverage-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9bebd9f4..4189e47f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,8 @@ default_language_version: - python: python3.9 + python: python3.11 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.5.0 hooks: - id: check-case-conflict - id: check-merge-conflict @@ -11,13 +11,13 @@ repos: - id: debug-statements - id: detect-private-key - repo: https://github.com/asottile/pyupgrade - rev: v2.31.0 + rev: v3.15.0 hooks: - id: pyupgrade args: - - --py36-plus - - repo: https://github.com/myint/autoflake - rev: v1.4 + - --py38-plus + - repo: https://github.com/pycqa/autoflake + rev: v2.2.1 hooks: - id: autoflake args: @@ -25,20 +25,20 @@ repos: - --remove-all-unused-imports - --ignore-init-module-imports - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 23.9.1 hooks: - id: black - - repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 + - repo: https://github.com/adamchainz/blacken-docs + rev: 1.16.0 hooks: - id: blacken-docs - additional_dependencies: [black==22.3.0] - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 + additional_dependencies: [black==23.9.1] + - repo: https://github.com/pycqa/flake8 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: @@ -46,7 +46,7 @@ repos: - flake8-comprehensions - flake8-tidy-imports - repo: https://github.com/sirosen/check-jsonschema - rev: 0.11.0 + rev: 0.27.0 hooks: - id: check-github-workflows # - repo: https://github.com/mgedmin/check-manifest diff --git a/Dockerfile b/Dockerfile index 7e62e8e2..16dd6146 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.10 # Install system dependencies RUN apt-get update && apt-get install -y gettext && \ diff --git a/djedi/__init__.py b/djedi/__init__.py index 1baba01f..80dab235 100644 --- a/djedi/__init__.py +++ b/djedi/__init__.py @@ -49,6 +49,7 @@ def configure(): "cio.plugins.txt.TextPlugin", "cio.plugins.md.MarkdownPlugin", "djedi.plugins.img.ImagePlugin", + "djedi.plugins.list.ListPlugin", ], "THEME": "darth", } diff --git a/djedi/admin/api.py b/djedi/admin/api.py index e15f4735..23d01b83 100644 --- a/djedi/admin/api.py +++ b/djedi/admin/api.py @@ -1,15 +1,18 @@ from collections import defaultdict +from urllib.parse import unquote from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse -from django.utils.http import urlunquote +from django.utils.safestring import mark_safe +from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.generic import View import cio +from cio.node import Node from cio.plugins import plugins from cio.plugins.exceptions import UnknownPlugin from cio.utils.uri import URI @@ -21,7 +24,7 @@ class APIView(View): - @csrf_exempt + @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): if not auth.has_permission(request): raise PermissionDenied @@ -64,7 +67,7 @@ def get_post_data(self, request): return data["data"], data["meta"] def decode_uri(self, uri): - decoded = urlunquote(uri) + decoded = unquote(uri) # If uri got decoded then recursive try more times until nothing more can be decoded if decoded != uri: @@ -77,7 +80,7 @@ def render_to_response(self, content=""): class NodeApi(JSONResponseMixin, APIView): - @never_cache + @method_decorator(never_cache) def get(self, request, uri): """ Return published node or specified version. @@ -152,7 +155,7 @@ def get(self, request, uri): class LoadApi(JSONResponseMixin, APIView): - @never_cache + @method_decorator(never_cache) def get(self, request, uri): """ Load raw node source from storage. @@ -173,33 +176,31 @@ def post(self, request, ext): try: plugin = plugins.get(ext) data, meta = self.get_post_data(request) - data = plugin.load(data) + uri = URI(ext=ext) + node = Node(uri=uri, content=data) + data = plugin.load_node(node) + node.content = data except UnknownPlugin: raise Http404 else: - content = plugin.render(data) + content = plugin.render_node(node, data) return self.render_to_response(content) class NodeEditor(JSONResponseMixin, DjediContextMixin, APIView): - @never_cache - @xframe_options_exempt + @method_decorator(never_cache) + @method_decorator(xframe_options_exempt) def get(self, request, uri): try: uri = self.decode_uri(uri) - uri = URI(uri) - plugin = plugins.resolve(uri) - plugin_context = self.get_context_data(uri=uri) - - if isinstance(plugin, DjediPlugin): - plugin_context = plugin.get_editor_context(**plugin_context) + plugin_context = self.get_plugin_context(request, uri) except UnknownPlugin: raise Http404 else: return self.render_plugin(request, plugin_context) - @never_cache + @method_decorator(never_cache) def post(self, request, uri): uri = self.decode_uri(uri) data, meta = self.get_post_data(request) @@ -208,17 +209,30 @@ def post(self, request, uri): context = cio.load(node.uri) context["content"] = node.content + context.update(self.get_plugin_context(request, context["uri"])) - if request.is_ajax(): + # is_ajax call? + if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest": return self.render_to_json(context) else: return self.render_plugin(request, context) + def get_plugin_context(self, request, uri): + uri = URI(uri) + plugin = plugins.resolve(uri) + + context = {"uri": uri, "plugin": uri.ext} + if isinstance(plugin, DjediPlugin): + context = plugin.get_editor_context(request, **context) + + context["uri"] = mark_safe(context["uri"]) + return context + def render_plugin(self, request, context): return TemplateResponse( request, [ - "djedi/plugins/%s/editor.html" % context["uri"].ext, + "djedi/plugins/%s/editor.html" % context["plugin"], "djedi/plugins/base/editor.html", ], self.get_context_data(**context), diff --git a/djedi/admin/cms.py b/djedi/admin/cms.py index 6fdb01b6..5123999e 100644 --- a/djedi/admin/cms.py +++ b/djedi/admin/cms.py @@ -1,7 +1,7 @@ -from django.conf.urls import include, url from django.contrib.admin import ModelAdmin from django.core.exceptions import PermissionDenied from django.shortcuts import render +from django.urls import include, re_path from django.views.decorators.clickjacking import xframe_options_exempt from django.views.generic import View @@ -10,14 +10,13 @@ class Admin(ModelAdmin): - verbose_name = "CMS" verbose_name_plural = verbose_name def get_urls(self): return [ - url(r"^", include("djedi.admin.urls", namespace="djedi")), - url( + re_path(r"^", include("djedi.admin.urls", namespace="djedi")), + re_path( r"", lambda: None, name="djedi_cms_changelist" ), # Placeholder to show change link to CMS in admin ] diff --git a/djedi/admin/mixins.py b/djedi/admin/mixins.py index 71c0af05..49523da5 100644 --- a/djedi/admin/mixins.py +++ b/djedi/admin/mixins.py @@ -1,9 +1,11 @@ import simplejson as json from django.conf import settings as django_settings from django.http import HttpResponse +from django.utils.safestring import mark_safe import djedi from cio.conf import settings +from cio.plugins import plugins # TODO: Switch simplejson to ujson or other? @@ -38,5 +40,6 @@ def get_context_data(self, **context): context["THEME"] = theme context["VERSION"] = djedi.__version__ + context["PLUGINS"] = mark_safe(json.dumps(list(plugins.plugins.keys()))) return context diff --git a/djedi/admin/urls.py b/djedi/admin/urls.py index 229b954a..b27c8c81 100644 --- a/djedi/admin/urls.py +++ b/djedi/admin/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.urls import include, re_path from .api import LoadApi, NodeApi, NodeEditor, PublishApi, RenderApi, RevisionsApi from .cms import DjediCMS @@ -6,12 +6,14 @@ app_name = "djedi" urlpatterns = [ - url(r"^$", DjediCMS.as_view(), name="cms"), - url(r"^node/(?P.+)/editor$", NodeEditor.as_view(), name="cms.editor"), - url(r"^node/(?P.+)/load$", LoadApi.as_view(), name="api.load"), - url(r"^node/(?P.+)/publish$", PublishApi.as_view(), name="api.publish"), - url(r"^node/(?P.+)/revisions$", RevisionsApi.as_view(), name="api.revisions"), - url(r"^node/(?P.+)$", NodeApi.as_view(), name="api"), - url(r"^plugin/(?P\w+)$", RenderApi.as_view(), name="api.render"), - url(r"^api/", include("djedi.rest.urls", namespace="rest")), + re_path(r"^$", DjediCMS.as_view(), name="cms"), + re_path(r"^node/(?P.+)/editor$", NodeEditor.as_view(), name="cms.editor"), + re_path(r"^node/(?P.+)/load$", LoadApi.as_view(), name="api.load"), + re_path(r"^node/(?P.+)/publish$", PublishApi.as_view(), name="api.publish"), + re_path( + r"^node/(?P.+)/revisions$", RevisionsApi.as_view(), name="api.revisions" + ), + re_path(r"^node/(?P.+)$", NodeApi.as_view(), name="api"), + re_path(r"^plugin/(?P\w+)$", RenderApi.as_view(), name="api.render"), + re_path(r"^api/", include("djedi.rest.urls", namespace="rest")), ] diff --git a/djedi/apps.py b/djedi/apps.py new file mode 100644 index 00000000..0b807987 --- /dev/null +++ b/djedi/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DjediConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "djedi" + verbose_name = "Djedi CMS" diff --git a/djedi/backends/django/cache/backend.py b/djedi/backends/django/cache/backend.py index 1d9fea3a..faef8c96 100644 --- a/djedi/backends/django/cache/backend.py +++ b/djedi/backends/django/cache/backend.py @@ -1,6 +1,6 @@ from django.core.cache import InvalidCacheBackendError, caches from django.core.cache.backends.locmem import LocMemCache -from django.utils.encoding import smart_bytes, smart_text +from django.utils.encoding import smart_bytes, smart_str from cio.backends.base import CacheBackend @@ -55,7 +55,7 @@ def _decode_content(self, content): """ Split node string to uri and content and convert back to unicode. """ - content = smart_text(content) + content = smart_str(content) uri, _, content = content.partition("|") if content == self.NONE: content = None diff --git a/djedi/backends/django/db/backend.py b/djedi/backends/django/db/backend.py index 52e3a837..38ab49e9 100644 --- a/djedi/backends/django/db/backend.py +++ b/djedi/backends/django/db/backend.py @@ -12,7 +12,6 @@ class DjangoModelStorageBackend(DatabaseBackend): - scheme = "db" def __init__(self, **config): @@ -32,7 +31,6 @@ def get_many(self, uris): # Assert requested plugin matches if uri.ext in (None, plugin): - # Assert version matches or node is published if (uri.version == version) or (is_published and not uri.version): meta = self._decode_meta(meta, is_published=is_published) diff --git a/djedi/backends/django/db/models.py b/djedi/backends/django/db/models.py index 63cfe7cb..1187d851 100644 --- a/djedi/backends/django/db/models.py +++ b/djedi/backends/django/db/models.py @@ -2,7 +2,6 @@ class Node(models.Model): - key = models.CharField(max_length=255, db_index=True) content = models.TextField(blank=True) plugin = models.CharField(max_length=8) @@ -12,5 +11,4 @@ class Node(models.Model): date_created = models.DateTimeField(auto_now_add=True) class Meta: - app_label = "djedi" db_table = "djedi_node" diff --git a/djedi/migrations/0001_initial.py b/djedi/migrations/0001_initial.py index b48b75bc..25deac8c 100644 --- a/djedi/migrations/0001_initial.py +++ b/djedi/migrations/0001_initial.py @@ -2,7 +2,6 @@ class Migration(migrations.Migration): - dependencies = [] operations = [ diff --git a/djedi/migrations/0002_auto_20190722_1447.py b/djedi/migrations/0002_auto_20190722_1447.py index 85065e03..10666b44 100644 --- a/djedi/migrations/0002_auto_20190722_1447.py +++ b/djedi/migrations/0002_auto_20190722_1447.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("djedi", "0001_initial"), ] diff --git a/djedi/migrations/0003_alter_node_id.py b/djedi/migrations/0003_alter_node_id.py new file mode 100644 index 00000000..82f2e945 --- /dev/null +++ b/djedi/migrations/0003_alter_node_id.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.6 on 2023-10-11 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djedi", "0002_auto_20190722_1447"), + ] + + operations = [ + migrations.AlterField( + model_name="node", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/djedi/plugins/base.py b/djedi/plugins/base.py index d6796236..8657f234 100644 --- a/djedi/plugins/base.py +++ b/djedi/plugins/base.py @@ -2,8 +2,8 @@ class DjediPlugin(BasePlugin): - def get_editor_context(self, **kwargs): + def get_editor_context(self, request, **context): """ Returns custom context """ - return kwargs + return context diff --git a/djedi/plugins/form.py b/djedi/plugins/form.py index 4d5e2a84..462b1f2c 100644 --- a/djedi/plugins/form.py +++ b/djedi/plugins/form.py @@ -43,8 +43,9 @@ class FormsBasePlugin(DjediPlugin): def forms(self): return {} - def get_editor_context(self, **context): - context.update({"forms": {tab: form() for tab, form in self.forms.items()}}) + def get_editor_context(self, request, **context): + if request.META.get("HTTP_X_REQUESTED_WITH") != "XMLHttpRequest": + context.update({"forms": {tab: form() for tab, form in self.forms.items()}}) return context diff --git a/djedi/plugins/list.py b/djedi/plugins/list.py new file mode 100644 index 00000000..cbe3eb14 --- /dev/null +++ b/djedi/plugins/list.py @@ -0,0 +1,172 @@ +import json + +import cio +from cio.node import Node +from cio.plugins import plugins + +from .base import DjediPlugin + + +class ListPlugin(DjediPlugin): + ext = "list" + + def get_editor_context(self, request, **context): + uri = context["uri"] + plugin_ext = self.get_query_param(uri, "plugin") + if plugin_ext: + context["plugin"] = plugin_ext + plugin = plugins.get(plugin_ext) + if isinstance(plugin, DjediPlugin): + context.update(plugin.get_editor_context(request, **context)) + + return context + + def load(self, content): + if content: + try: + return json.loads(content) + except ValueError: + pass + return {"direction": "col", "children": []} + + def load_node(self, node): + list_data = self.load(node.content) + + # Root data + if self.is_leaf_list_node(node.uri): + return list_data + + # Child data + child_node, key = self.get_child_node(node.uri, list_data) + plugin = self.resolve_child_plugin(child_node.uri) + + return plugin.load_node(child_node) + + def render_node(self, node, data): + if not self.is_leaf_list_node(node.uri): + child_plugin = self.resolve_child_plugin(node.uri) + if child_plugin: + child_uri, _ = self.get_child_uri(node.uri) + return child_plugin.render_node(Node(uri=child_uri), data) + + return "".join(self.stream_node(node, data)) + + def stream_node(self, node, data): + yield '
    '.format(**data) + for child in data["children"]: + ext = child["plugin"] + child_uri = node.uri.clone(query={"plugin": [ext], "key": [child["key"]]}) + child_node = Node(uri=child_uri, content=child["data"]) + plugin = plugins.get(ext) + content = plugin.load_node(child_node) + yield '
  • '.format(**child) + yield plugin.render_node(child_node, content) or "" + yield "
  • " + yield "
" + + def resolve_child_plugin(self, uri): + if self.is_nested(uri): + ext = self.ext + else: + ext = self.get_query_param(uri, "plugin") + + return plugins.get(ext or self.ext) + + def find_child(self, data, key): + if not key: + return None + + for child in data["children"]: + if child["key"] == key: + return child + + return None + + def get_child_node(self, uri, parent_data, default=None): + # TODO: modify uri or content instead of new Nodes? + child_uri, key = self.get_child_uri(uri) + child = self.find_child(parent_data, key) + content = child["data"] if child else default + return Node(uri=child_uri, content=content), key + + def get_query_param(self, uri, param): + value = (uri.query or {}).get(param) + return value[0] if value else "" + + def is_nested(self, uri): + return bool(self.get_query_param(uri, "key")) + + def get_child_key(self, uri): + key = self.get_query_param(uri, "key") + if not key: + return None, None + + key, _, rest = key.partition("_") + return key, rest + + def get_child_uri(self, uri): + key, rest = self.get_child_key(uri) + + if uri.query: + query = dict(uri.query) + if not rest: + query.pop("key", None) + else: + query["key"] = [rest] + uri = uri.clone(query=query) + + return uri, key + + def is_leaf_list_node(self, uri): + plugin = self.get_query_param(uri, "plugin") + return not plugin or plugin == self.ext and not self.is_nested(uri) + + def save_node(self, node): + if not self.get_query_param(node.uri, "plugin"): + return node + + root_node = cio.load(node.uri.clone(query=None)) + root_data = root_node["data"] or self.load(None) + + node.content = self.save_child( + node.content, parent_node=node, parent_data=root_data + ) # TODO: deep clone data? + + return node + + def save_child(self, leaf_data, parent_node, parent_data): + child_node, key = self.get_child_node( + parent_node.uri, parent_data, default=parent_node.content + ) + + plugin = self.resolve_child_plugin(child_node.uri) + if plugin.ext == self.ext: + if not self.is_nested(child_node.uri): + child_content = leaf_data + else: + child_data = self.load(child_node.content) + child_content = self.save_child( + leaf_data, parent_node=child_node, parent_data=child_data + ) + else: + child_node.content = leaf_data + child_node = plugin.save_node(child_node) + child_content = child_node.content + + child = self.find_child(parent_data, key) + + if child: + child["data"] = child_content + else: + parent_data["children"].append( + { + "key": key, + "plugin": plugin.ext, + "data": child_content, + } + ) + + return self.save(parent_data) + + def save(self, content): + return json.dumps(content) diff --git a/djedi/rest/api.py b/djedi/rest/api.py index 8f130f62..abcaf51d 100644 --- a/djedi/rest/api.py +++ b/djedi/rest/api.py @@ -1,5 +1,6 @@ import simplejson as json from django.http import HttpResponse +from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt from django.views.generic import View @@ -36,7 +37,7 @@ class NodesApi(APIView): {uri: content, uri: content, ...} """ - @never_cache + @method_decorator(never_cache) def post(self, request): # Disable caching gets in CachePipe, defaults through this api is not trusted cio.conf.settings.configure(local=True, CACHE={"PIPE": {"CACHE_ON_GET": False}}) diff --git a/djedi/rest/urls.py b/djedi/rest/urls.py index d1fb85bf..105169c7 100644 --- a/djedi/rest/urls.py +++ b/djedi/rest/urls.py @@ -1,5 +1,5 @@ -from django.conf.urls import url from django.http import Http404 +from django.urls import re_path from .api import EmbedApi, NodesApi @@ -11,7 +11,7 @@ def not_found(*args, **kwargs): urlpatterns = [ - url(r"^$", not_found, name="api-base"), - url(r"^embed/$", EmbedApi.as_view(), name="embed"), - url(r"^nodes/$", NodesApi.as_view(), name="nodes"), + re_path(r"^$", not_found, name="api-base"), + re_path(r"^embed/$", EmbedApi.as_view(), name="embed"), + re_path(r"^nodes/$", NodesApi.as_view(), name="nodes"), ] diff --git a/djedi/static/djedi/cms/js/cms.coffee b/djedi/static/djedi/cms/js/cms.coffee index a99e56c0..847d8c91 100644 --- a/djedi/static/djedi/cms/js/cms.coffee +++ b/djedi/static/djedi/cms/js/cms.coffee @@ -35,7 +35,7 @@ class Settings ################################################[ NODE ]############################################################## -class Node +class window.Node selected: no @@ -253,7 +253,7 @@ class Page ################################################[ PLUGIN ]############################################################ -class Plugin +window.Plugin = class Plugin constructor: (@node) -> @uri = @node.uri.valueOf() @@ -388,5 +388,5 @@ class CMS else @page.$cms.css style - -new CMS +window.makeCms = -> + new CMS diff --git a/djedi/static/djedi/cms/js/cms.js b/djedi/static/djedi/cms/js/cms.js index febb61a1..12fe501d 100644 --- a/djedi/static/djedi/cms/js/cms.js +++ b/djedi/static/djedi/cms/js/cms.js @@ -1,6 +1,6 @@ // Generated by CoffeeScript 1.8.0 (function() { - var CMS, Events, Node, Page, Plugin, Search, Settings, + var CMS, Events, Page, Plugin, Search, Settings, __slice = [].slice, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; @@ -55,7 +55,7 @@ })(); - Node = (function() { + window.Node = (function() { Node.prototype.selected = false; function Node(uri, data, container) { @@ -340,7 +340,7 @@ })(); - Plugin = (function() { + window.Plugin = Plugin = (function() { function Plugin(node) { this.node = node; this.connect = __bind(this.connect, this); @@ -514,6 +514,8 @@ })(); - new CMS; + window.makeCms = function() { + return new CMS; + }; }).call(this); diff --git a/djedi/static/djedi/cms/js/uri.coffee b/djedi/static/djedi/cms/js/uri.coffee index c6020907..a20ecf9f 100644 --- a/djedi/static/djedi/cms/js/uri.coffee +++ b/djedi/static/djedi/cms/js/uri.coffee @@ -15,6 +15,7 @@ String::to_uri = -> s += @namespace + '@' if @namespace s += @path s += '.' + @ext if @ext + s += '?' + @stringify_query(@query) if @query s += '#' + @version if @version s #@scheme + '://' + @namespace + '@' + @path + '.' + @ext + '#' + @version @@ -27,6 +28,7 @@ String::to_uri = -> @path = obj.path @ext = obj.ext @version = obj.version + @query = obj.query @parts = scheme: @scheme @@ -34,9 +36,18 @@ String::to_uri = -> path: @path ext: @ext version: @version + query: @query @parse = (uri_str) -> [base, _, version] = partition(uri_str, '#') + if base.indexOf('?') != -1 + [base, _, querystring] = rpartition(base,'?') + param_pairs = querystring.split('&') + query = {} + for pair in param_pairs + [key, _, val] = partition(pair, '=') + if !query[key] or query[key].length == 0 + query[key] = [decodeURIComponent(val)] [scheme, _, path] = rpartition(base, '://') [namespace, _, path] = rpartition(path, '@') [path, _, ext] = partition(path, '.') @@ -46,12 +57,31 @@ String::to_uri = -> path: path ext: ext or null version: version or null + query: query or null + @from_str = (uri_str) -> @from_parts @parse uri_str @clone = (obj) -> - @from_parts _.extend(obj, @parts) + parts = Object.assign({}, @parts) + _uri = ((' ' + @valueOf()).slice(1)).to_uri() + for key, val of obj + parts[key] = val + _uri.from_parts(parts) + return _uri + + @stringify_query = (query) -> + simplified_query = {} + for key, arr of @query + simplified_query[key] = arr[0] + return $.param(simplified_query) + + @get_query_param = (key) -> + if @query and @query[key] and @query[key].length > 0 + return @query[key][0] + else + return undefined @from_parts @parse @ @ diff --git a/djedi/static/djedi/cms/js/uri.js b/djedi/static/djedi/cms/js/uri.js index 755cd517..879c8afc 100644 --- a/djedi/static/djedi/cms/js/uri.js +++ b/djedi/static/djedi/cms/js/uri.js @@ -28,6 +28,9 @@ if (this.ext) { s += '.' + this.ext; } + if (this.query) { + s += '?' + this.stringify_query(this.query); + } if (this.version) { s += '#' + this.version; } @@ -42,33 +45,73 @@ this.path = obj.path; this.ext = obj.ext; this.version = obj.version; + this.query = obj.query; return this.parts = { scheme: this.scheme, namespace: this.namespace, path: this.path, ext: this.ext, - version: this.version + version: this.version, + query: this.query }; }; this.parse = function(uri_str) { - var base, ext, namespace, path, scheme, version, _, _ref, _ref1, _ref2, _ref3; + var base, ext, key, namespace, pair, param_pairs, path, query, querystring, scheme, val, version, _, _i, _len, _ref, _ref1, _ref2, _ref3, _ref4, _ref5; _ref = partition(uri_str, '#'), base = _ref[0], _ = _ref[1], version = _ref[2]; - _ref1 = rpartition(base, '://'), scheme = _ref1[0], _ = _ref1[1], path = _ref1[2]; - _ref2 = rpartition(path, '@'), namespace = _ref2[0], _ = _ref2[1], path = _ref2[2]; - _ref3 = partition(path, '.'), path = _ref3[0], _ = _ref3[1], ext = _ref3[2]; + if (base.indexOf('?') !== -1) { + _ref1 = rpartition(base, '?'), base = _ref1[0], _ = _ref1[1], querystring = _ref1[2]; + param_pairs = querystring.split('&'); + query = {}; + for (_i = 0, _len = param_pairs.length; _i < _len; _i++) { + pair = param_pairs[_i]; + _ref2 = partition(pair, '='), key = _ref2[0], _ = _ref2[1], val = _ref2[2]; + if (!query[key] || query[key].length === 0) { + query[key] = [decodeURIComponent(val)]; + } + } + } + _ref3 = rpartition(base, '://'), scheme = _ref3[0], _ = _ref3[1], path = _ref3[2]; + _ref4 = rpartition(path, '@'), namespace = _ref4[0], _ = _ref4[1], path = _ref4[2]; + _ref5 = partition(path, '.'), path = _ref5[0], _ = _ref5[1], ext = _ref5[2]; return { scheme: scheme || null, namespace: namespace || null, path: path, ext: ext || null, - version: version || null + version: version || null, + query: query || null }; }; this.from_str = function(uri_str) { return this.from_parts(this.parse(uri_str)); }; this.clone = function(obj) { - return this.from_parts(_.extend(obj, this.parts)); + var key, parts, val, _uri; + parts = Object.assign({}, this.parts); + _uri = ((' ' + this.valueOf()).slice(1)).to_uri(); + for (key in obj) { + val = obj[key]; + parts[key] = val; + } + _uri.from_parts(parts); + return _uri; + }; + this.stringify_query = function(query) { + var arr, key, simplified_query, _ref; + simplified_query = {}; + _ref = this.query; + for (key in _ref) { + arr = _ref[key]; + simplified_query[key] = arr[0]; + } + return $.param(simplified_query); + }; + this.get_query_param = function(key) { + if (this.query && this.query[key] && this.query[key].length > 0) { + return this.query[key][0]; + } else { + return void 0; + } }; this.from_parts(this.parse(this)); return this; diff --git a/djedi/static/djedi/plugins/base/js/editor.coffee b/djedi/static/djedi/plugins/base/js/editor.coffee index 7131e4bb..6dd8cbd9 100644 --- a/djedi/static/djedi/plugins/base/js/editor.coffee +++ b/djedi/static/djedi/plugins/base/js/editor.coffee @@ -134,8 +134,12 @@ class window.Editor @$version = $ 'header .version' @$flag = $ 'header .flag' - $('#button-publish').on 'click', @publish - $('#button-discard').on 'click', @discard + @$doc.on 'editor:save', () => @$form.submit() + @$doc.on 'editor:publish', () => @onPublish() + + @actions.publish.on 'click', @publish + @actions.discard.on 'click', @discard + @actions.save.on 'click', @save # Use ajaxForm from downloads @$form.ajaxForm @@ -153,6 +157,8 @@ class window.Editor @api.load config.uri, @onLoad @callback 'initialize', config @initialized = yes + window.editor = @ + @trigger 'editor:initialized', @, config callback: (name, args...) -> callback = @config[name] @@ -171,7 +177,7 @@ class window.Editor prepareForm: -> onLoad: (node) => - console.log 'Editor.onLoad()', node.uri + console.log 'Editor.onLoad()' initial = @node == undefined # Fetch default node data from embedder @@ -196,6 +202,7 @@ class window.Editor onFormChange: (event) => console.log 'Editor.onFormChange()' + @trigger 'editor:dirty' @setState 'dirty' @callback 'onFormChange', event @@ -203,8 +210,14 @@ class window.Editor console.log 'Editor.onSave()' node = @setNode node @render node + @trigger 'node:update', node.uri.valueOf(), node @trigger 'node:render', node.uri.valueOf(), node.content + onPublish: () => + node = @api.publish @node.uri.valueOf() + @setNode node + @setState 'published' + setNode: (node) -> console.log 'Editor.setNode()' @node = node @@ -232,6 +245,7 @@ class window.Editor setState: (state) -> console.log 'Editor.setState()', state if state != @state + oldState = @state @state = state @$version.removeClass 'label-default label-warning label-danger label-info label-success' switch state @@ -260,20 +274,22 @@ class window.Editor @actions.discard.disable() @actions.save.disable() @actions.publish.enable() + @trigger 'editor:state-changed', oldState, state, @node + renderHeader: (node) -> uri = node.uri - color = (uri.ext[0].toUpperCase().charCodeAt() - 65) % 5 + 1 + color = @getPluginColor(uri.ext) parts = ( for part in uri.path.split '/' when part != '' (part[..0].toUpperCase() + part[1..-1]).replace /[_-]/g, ' ' ) - path = parts.join " / " + path = parts.join " / " lang = uri.namespace.split('-')[0] if uri.scheme == 'i18n' - @$plugin.html(uri.ext).addClass "plugin-fg-#{color}" + @$plugin.html(uri.ext).addClass color @$path.html path @$flag.addClass "flag-#{lang}" @@ -329,13 +345,18 @@ class window.Editor render: (node) -> console.log 'Editor.render()' + @trigger 'editor:render', node @callback 'render', node loadRevision: (event) => console.log 'Editor.loadRevision()' event.preventDefault() - $revision = $ event.target + if $(event.target).is('i') + $revision = $(event.target).parent() + else + $revision = $ event.target + uri = $revision.data('uri') published = $revision.data 'published' @@ -354,7 +375,12 @@ class window.Editor renderContent: (data, doTrigger, callback) -> console.log 'Editor.renderContent()' - plugin = @node.uri.ext + + if @node.uri.query and @node.uri.query['plugin'] + plugin = @node.uri.query['plugin'] + else + plugin = @node.uri.ext + data = {data: data} if typeof(data) == 'string' content = '' @@ -372,9 +398,8 @@ class window.Editor content publish: => - node = @api.publish @node.uri.valueOf() - @setNode node - @setState 'published' + if @state == "draft" || @state == "revert" + @trigger "editor:publish", @node.uri discard: => if @node.uri.version == 'draft' @@ -385,3 +410,12 @@ class window.Editor @node = null @api.load uri.valueOf(), @onLoad + @trigger "editor:discard", uri + + save: => + if @state == "dirty" + @trigger 'editor:save', @node.uri + + getPluginColor: (ext) => + color = (ext[0].toUpperCase().charCodeAt() - 65) % 5 + 1 + return "plugin-fg-#{color}" diff --git a/djedi/static/djedi/plugins/base/js/editor.js b/djedi/static/djedi/plugins/base/js/editor.js index cd897506..6643229a 100644 --- a/djedi/static/djedi/plugins/base/js/editor.js +++ b/djedi/static/djedi/plugins/base/js/editor.js @@ -156,9 +156,12 @@ window.Editor = (function() { function Editor(config) { this.config = config; + this.getPluginColor = __bind(this.getPluginColor, this); + this.save = __bind(this.save, this); this.discard = __bind(this.discard, this); this.publish = __bind(this.publish, this); this.loadRevision = __bind(this.loadRevision, this); + this.onPublish = __bind(this.onPublish, this); this.onSave = __bind(this.onSave, this); this.onFormChange = __bind(this.onFormChange, this); this.onLoad = __bind(this.onLoad, this); @@ -191,8 +194,19 @@ this.$path = $('header .uri'); this.$version = $('header .version'); this.$flag = $('header .flag'); - $('#button-publish').on('click', this.publish); - $('#button-discard').on('click', this.discard); + this.$doc.on('editor:save', (function(_this) { + return function() { + return _this.$form.submit(); + }; + })(this)); + this.$doc.on('editor:publish', (function(_this) { + return function() { + return _this.onPublish(); + }; + })(this)); + this.actions.publish.on('click', this.publish); + this.actions.discard.on('click', this.discard); + this.actions.save.on('click', this.save); this.$form.ajaxForm({ beforeSubmit: this.prepareForm, success: this.onSave @@ -209,7 +223,9 @@ }); this.api.load(config.uri, this.onLoad); this.callback('initialize', config); - return this.initialized = true; + this.initialized = true; + window.editor = this; + return this.trigger('editor:initialized', this, config); }; Editor.prototype.callback = function() { @@ -240,7 +256,7 @@ Editor.prototype.onLoad = function(node) { var initial; - console.log('Editor.onLoad()', node.uri); + console.log('Editor.onLoad()'); initial = this.node === void 0; if (initial) { this.trigger('page:node:fetch', node.uri.valueOf(), (function(_this) { @@ -267,6 +283,7 @@ Editor.prototype.onFormChange = function(event) { console.log('Editor.onFormChange()'); + this.trigger('editor:dirty'); this.setState('dirty'); return this.callback('onFormChange', event); }; @@ -275,9 +292,17 @@ console.log('Editor.onSave()'); node = this.setNode(node); this.render(node); + this.trigger('node:update', node.uri.valueOf(), node); return this.trigger('node:render', node.uri.valueOf(), node.content); }; + Editor.prototype.onPublish = function() { + var node; + node = this.api.publish(this.node.uri.valueOf()); + this.setNode(node); + return this.setState('published'); + }; + Editor.prototype.setNode = function(node) { console.log('Editor.setNode()'); this.node = node; @@ -305,8 +330,10 @@ }; Editor.prototype.setState = function(state) { + var oldState; console.log('Editor.setState()', state); if (state !== this.state) { + oldState = this.state; this.state = state; this.$version.removeClass('label-default label-warning label-danger label-info label-success'); switch (state) { @@ -314,35 +341,40 @@ this.$version.addClass('label-default'); this.actions.discard.disable(); this.actions.save.enable(); - return this.actions.publish.disable(); + this.actions.publish.disable(); + break; case 'dirty': this.$version.addClass('label-danger'); this.actions.discard.enable(); this.actions.save.enable(); - return this.actions.publish.disable(); + this.actions.publish.disable(); + break; case 'draft': this.$version.addClass('label-primary'); this.actions.discard.enable(); this.actions.save.disable(); - return this.actions.publish.enable(); + this.actions.publish.enable(); + break; case 'published': this.$version.addClass('label-success'); this.actions.discard.disable(); this.actions.save.disable(); - return this.actions.publish.disable(); + this.actions.publish.disable(); + break; case 'revert': this.$version.addClass('label-warning'); this.actions.discard.disable(); this.actions.save.disable(); - return this.actions.publish.enable(); + this.actions.publish.enable(); } + return this.trigger('editor:state-changed', oldState, state, this.node); } }; Editor.prototype.renderHeader = function(node) { var color, lang, part, parts, path, uri, v; uri = node.uri; - color = (uri.ext[0].toUpperCase().charCodeAt() - 65) % 5 + 1; + color = this.getPluginColor(uri.ext); parts = (function() { var _i, _len, _ref, _results; _ref = uri.path.split('/'); @@ -355,11 +387,11 @@ } return _results; })(); - path = parts.join(" / "); + path = parts.join(" / "); if (uri.scheme === 'i18n') { lang = uri.namespace.split('-')[0]; } - this.$plugin.html(uri.ext).addClass("plugin-fg-" + color); + this.$plugin.html(uri.ext).addClass(color); this.$path.html(path); this.$flag.addClass("flag-" + lang); v = this.$version.find('var'); @@ -413,6 +445,7 @@ Editor.prototype.render = function(node) { console.log('Editor.render()'); + this.trigger('editor:render', node); return this.callback('render', node); }; @@ -420,7 +453,11 @@ var $revision, data, published, uri; console.log('Editor.loadRevision()'); event.preventDefault(); - $revision = $(event.target); + if ($(event.target).is('i')) { + $revision = $(event.target).parent(); + } else { + $revision = $(event.target); + } uri = $revision.data('uri'); published = $revision.data('published'); if (uri.version) { @@ -448,7 +485,11 @@ Editor.prototype.renderContent = function(data, doTrigger, callback) { var content, plugin; console.log('Editor.renderContent()'); - plugin = this.node.uri.ext; + if (this.node.uri.query && this.node.uri.query['plugin']) { + plugin = this.node.uri.query['plugin']; + } else { + plugin = this.node.uri.ext; + } if (typeof data === 'string') { data = { data: data @@ -476,10 +517,9 @@ }; Editor.prototype.publish = function() { - var node; - node = this.api.publish(this.node.uri.valueOf()); - this.setNode(node); - return this.setState('published'); + if (this.state === "draft" || this.state === "revert") { + return this.trigger("editor:publish", this.node.uri); + } }; Editor.prototype.discard = function() { @@ -490,7 +530,20 @@ uri = this.node.uri; uri.version = null; this.node = null; - return this.api.load(uri.valueOf(), this.onLoad); + this.api.load(uri.valueOf(), this.onLoad); + return this.trigger("editor:discard", uri); + }; + + Editor.prototype.save = function() { + if (this.state === "dirty") { + return this.trigger('editor:save', this.node.uri); + } + }; + + Editor.prototype.getPluginColor = function(ext) { + var color; + color = (ext[0].toUpperCase().charCodeAt() - 65) % 5 + 1; + return "plugin-fg-" + color; }; return Editor; diff --git a/djedi/static/djedi/plugins/img/js/img.coffee b/djedi/static/djedi/plugins/img/js/img.coffee index 29b407e1..4142286c 100644 --- a/djedi/static/djedi/plugins/img/js/img.coffee +++ b/djedi/static/djedi/plugins/img/js/img.coffee @@ -319,6 +319,8 @@ class window.ImageEditor extends window.Editor @field = $ config.field @preview = $ config.preview + @enable_crop_preview = config.enable_crop_preview + @dropzone = new Dropzone field: @field el: config.dropzone @@ -413,7 +415,9 @@ class window.ImageEditor extends window.Editor $image.on 'crop:preview', (event, html) => @crop?.setPreviewAttributes @getHtmlFields() # This replaces the image on the page with the preview image. - @triggerRender html + + if @enable_crop_preview + @triggerRender html $image.on 'crop:attributes', => @updateImageAttributes() if @crop diff --git a/djedi/static/djedi/plugins/img/js/img.js b/djedi/static/djedi/plugins/img/js/img.js index 419e8b4e..b9650400 100644 --- a/djedi/static/djedi/plugins/img/js/img.js +++ b/djedi/static/djedi/plugins/img/js/img.js @@ -331,6 +331,7 @@ this.firstRender = true; this.field = $(config.field); this.preview = $(config.preview); + this.enable_crop_preview = config.enable_crop_preview; this.dropzone = new Dropzone({ field: this.field, el: config.dropzone @@ -461,7 +462,9 @@ if ((_ref = _this.crop) != null) { _ref.setPreviewAttributes(_this.getHtmlFields()); } - return _this.triggerRender(html); + if (_this.enable_crop_preview) { + return _this.triggerRender(html); + } }); $image.on('crop:attributes', function() { _this.updateImageAttributes(); diff --git a/djedi/static/djedi/plugins/list/css/list.css b/djedi/static/djedi/plugins/list/css/list.css new file mode 100644 index 00000000..4a82e29e --- /dev/null +++ b/djedi/static/djedi/plugins/list/css/list.css @@ -0,0 +1,190 @@ +#editor { + flex: 1; + max-height: none; + height: auto; + overflow: auto; + padding: 0; +} + +#editor .helpers { + padding: 15px; + margin-bottom: 15px; + border-bottom: 1px dashed #ccc; + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#editor .helpers #direction-options { + flex: 1; +} + +#editor .helpers .direction-title { + margin-bottom: 0; + margin-right: 5px; + vertical-align: middle; +} + +#editor .helpers #direction-options .radio { + display: inline-block; + margin: 0; + margin-right: 10px; + +} + +.subnodes { + display: flex; + flex-direction: column; + margin-left: 5px; +} + +.subnodes #editor-iframe { + height: 355px; +} + +.subnodes__item { + display: block; + margin-bottom: 15px; +} + +.subnodes__item-remove { + display: inline-block; + width: 25px; + cursor: pointer; + order: 2; + text-align: center; + height: 25px; + line-height: 24px; + border-radius: 20px; + margin-right: 1px; +} + +.subnodes__item-remove:hover { + background-color: #6a6a6a; +} + +.subnodes__item-drag { + display: block; + background-color: #3a3a3a; + text-align: center; + padding: 1px 0px; + font-size: 16px; + cursor: move; +} + +.subnodes__item-shift { + display:flex; + flex-direction: column; + width: 30px; + order: 3; +} + +.subnodes__item-shift > a { + flex: 1; + text-align: center; + color: white; + cursor: pointer; + } + +.subnodes__item-shift > a:hover { + color:black; + text-decoration: none; + background-color: #6a6a6a; +} + +.subnodes__item-shift--disabled > a { + cursor: not-allowed; +} + +.subnodes__item-shift--disabled > a:hover { + color:#999; +} + +.subnodes__item-title { + display: flex; + flex-direction: row; + align-items: center; + + background: rgba(0, 0, 0, 0.25); + border-bottom: 1px solid #666; + text-transform: uppercase; + -webkit-box-shadow: none; + box-shadow: none; + text-shadow: 0px 1px 2px #111; + padding: 0; + padding-left: 45px; + font-weight: bold; + position: relative; +} + +.subnodes__item-title__text { + font-size: 9px; + flex: 1; +} + +.subnodes__item-title:after { + position: absolute; + top: 50%; + left: 15px; + transform: translateY(-50%); + + display: block; + content: ' '; + cursor: pointer; + + + border: inset; + border-bottom: 6px solid white; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 0px; +} + +.subnodes__item--closed .subnodes__item-title:after { + border-top: 6px solid white; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 0px; +} + +.subnodes__item-content { + +} + +.subnodes__item--closed .subnodes__item-content { + display: none; +} + +#subnode-data { + display: none; +} + +.djedi-list { + list-style-type: none; + margin: 0; + padding: 0; + display: flex; +} + +.djedi-list li { +} + +.djedi-list--col { + flex-direction: column; +} + +.djedi-list--ucol { + flex-direction: column-reverse; +} + +.djedi-list--row { + flex-direction: row; +} + +.djedi-list--urow { + flex-direction: row-reverse; +} + +.node-add { + text-transform: uppercase; +} diff --git a/djedi/static/djedi/plugins/list/js/list.coffee b/djedi/static/djedi/plugins/list/js/list.coffee new file mode 100644 index 00000000..fbb5b347 --- /dev/null +++ b/djedi/static/djedi/plugins/list/js/list.coffee @@ -0,0 +1,347 @@ + + +################################################[ EDITOR ]############################################################ +class window.ListEditor extends window.Editor + + initDataStructure: () -> + return { + direction: 'col', + children: [] + } + + + initialize: (config) -> + console.log 'ListEditor.initialize', @ + + super config + @subnodeCss = ' + + '; + + @editor = this; + @subnodeIframes = [] + @data = @initDataStructure() + @saveQueue = [] + @loading = false + @preventParentReload = false + @subnodeDirty = false + @doShallowSave = false + + @container = $('#node-list') + @dataHolder = $('#subnode-data') + @directions = $('#direction-options') + @editor.$add_list = $('#plugin-list') + + $('#form input').unbind() + $('#form textarea').unbind() + $('#form select').unbind() + + @directions.find('input').on 'change', (e) => + @setDirection e.target.value + + for plg in config.plugins + if plg != 'list' + $("
  • #{plg}
  • ").appendTo @editor.$add_list + + @editor.$add = $('.node-add') + @editor.$add.on 'click', (evt) => + if (!@subnodeDirty) + @addSubnode(@getSubnodeUriKey(), $(evt.target).text(), true) + + setDirection: (dir, refreshData = true) => + @directions.find('[name="direction"]').prop('checked', false); + target = @directions.find("[value='#{dir}']"); + if target.length == 1 + target.prop('checked', true); + @data.direction = dir + @updateData(refreshData) + @setDirty() + else + @setDirection "col", refreshData; + + addSubnode: (key, plugin, markDirty, defaultData = "") => + @spawnSubnode @node.uri.clone({ + query: { + key: [key], + plugin: [plugin] + }, + version: "", + }).valueOf(), markDirty, defaultData + + onLoad: (node) => + @loading = true + @clearList() + super node + @frameBias = "node/" + encodeURIComponent((encodeURIComponent(node.uri.valueOf().replace('#'+node.uri.version, '')))) + "/editor" + try + codedData = node.data + for entry in codedData.children + @addSubnode(@getSubnodeUriKey(entry.key), entry.plugin, false, entry.data) + @setDirection codedData.direction, false + catch exception + @clearList() + @updateData(true) + console.log "ListEditor.onLoad(), error when loading. Data invalid: ", exception + @loading = false + + render: (node) => + console.log('ListEditor.render()', node.content, @) + @dataHolder.val(JSON.stringify(@data)) + super node + + setState: (state) => + if state == 'draft' && @preventParentReload || state == 'dirty' && @loading + return + if state == "dirty" && @subnodeDirty + @toggleListActions() + super state + + spawnSubnode: (uri, refreshValue = true, data = "") => + console.log("ListEditor.spawnSubNode()") + classes = 'subnodes__item' + + node_container = $("
    ").appendTo @container + title = $("
    ").appendTo node_container + holder = $("
    ").appendTo node_container + + title.on 'click', (e) => + $(e.target).parent().toggleClass 'subnodes__item--closed' + + $("
    ").appendTo(title).on 'click', (e) => + @popSubnode($(e.target).parents('.subnodes__item').attr "uri-ref") + + handle = $("
    + + +
    ").prependTo title + handle.find('a').on 'click', (event) => + if @subnodeDirty + return false + newOrder = false + if ($(event.target).hasClass('subnodes__item-shift--up') || $(event.target).hasClass('icon-chevron-up')) + newOrder = @moveChild(uri, -1) + else + newOrder = @moveChild(uri, 1) + if newOrder != false + @resortNodes() + @updateData true + @setDirty() + @shallowSave() + + node = new window.Node uri, data, holder + title.append (""+(node.uri.get_query_param('plugin') or 'unknown')+"") + title.find('.subnodes__item-title__text').addClass(@getPluginColor(node.uri.get_query_param('plugin') or 'plugin-fg-unknown')) + + node_container.attr 'uri-ref', node.uri.valueOf() + node_container.attr 'data-key', node.uri.get_query_param('key') + + node_iframe = new window.Plugin node + + ref_uri = @node.uri.clone({ + version: "" + }).valueOf() + + path = document.location.pathname.replace("node/#{encodeURIComponent(encodeURIComponent ref_uri)}/editor", "") + path = path.replace("node/#{encodeURIComponent(encodeURIComponent @node.uri)}/editor", "") + node_iframe.$el.attr 'src', path + "node/#{encodeURIComponent(encodeURIComponent uri)}/editor" + + node_container.css('order', @data.children.length); + @subnodeIframes.push node_iframe + @data.children.push { + key: @getSubnodeKey(node.uri.get_query_param('key')), + plugin: node.uri.get_query_param('plugin'), + data: data, + } + holder.append node_iframe.$el + + windowRef = node_iframe.$el[0].contentWindow + + $(node_iframe.$el).on 'load', () => + head = windowRef.$(node_iframe.$el[0]).contents().find("head"); + windowRef.$(head).append(@subnodeCss) + windowRef.$(windowRef.document).on 'editor:state-changed', (event, oldState, newState, node) => + if oldState == 'dirty' && newState == 'draft' + @workSaveQueue() + @updateSubnode(node.uri.to_uri().get_query_param('key'), node) + + windowRef.$(windowRef.document).on 'editor:dirty', () => + @subnodeDirty = true + @setDirty() + + windowRef.$(windowRef.document).on 'node:update', (event, uri, node) => + @updateSubnode(node.uri.to_uri().get_query_param('key'), node) + + windowRef.$(windowRef.document).on 'node:render', (event, uri, content) => + @renderSubnode(uri, content) + + @updateData(refreshValue) + if refreshValue + @setDirty() + + save: () -> + @preventParentReload = true + for subnode_iframe in @subnodeIframes + @saveQueue.push(subnode_iframe) + super + + shallowSave: () -> + @doShallowSave = true + if @state == "dirty" + @trigger 'editor:save', @node.uri + + onSave: (node) -> + super node + if !@doShallowSave + @workSaveQueue() + else + @doShallowSave = false; + + onPublish: () => + super + @loadRevisionByClass('.published') + @setState 'published' + + workSaveQueue: () => + console.log "ListEditor.workSaveQueue()", @saveQueue.length + if @saveQueue.length > 0 + @saveSubnode(@saveQueue.pop()) + else + @preventParentReload = false + @loadRevisionByClass('.draft') + @setState('draft') + @subnodeDirty = false + @toggleListActions(true) + + saveSubnode: (plugin) => + windowRef = plugin.$el[0].contentWindow + if windowRef and windowRef.editor and windowRef.editor.state != 'dirty' + @workSaveQueue() + else if windowRef and windowRef.editor + windowRef.editor.save() + + popSubnode: (uri) => + console.log("ListEditor.popSubnode()") + targetUri = uri + targetKey = @getSubnodeKey(targetUri.to_uri().get_query_param('key')) + @subnodeIframes = @subnodeIframes.filter (value) => + if value.uri.valueOf() != targetUri + return true + + value.close() + @container.find('[uri-ref="'+targetUri+'"]').remove() + return false + + @data.children = @data.children.filter (value) -> + if value.key != targetKey + return true + return false + @setDirty() + @updateData(true) + + clearList: () => + @container.empty() + @subnodeIframes = [] + @data = @initDataStructure() + + updateData: (reRender = false) => + collection = JSON.stringify @data + + @dataHolder.val collection + @dataHolder.change() + + @node.data = collection + + if (reRender) + @api.render "list", { + data: collection + }, (response) => + contentSet = $(response)[0] + @node.content = contentSet + @editor.triggerRender (@node.content) + + renderSubnode: (uri, content) => + console.log("ListEditor.renderSubnode()") + key = @getSubnodeKey(decodeURIComponent(uri.to_uri().get_query_param('key'))) + newContent = $(@node.content).find('#'+key).html(content).end()[0]; + @updateData(false) + @node.content = newContent + @editor.triggerRender newContent + + updateSubnode: (uuid, node, norender = false) => + console.log("ListEditor.updateSubnode()", uuid) + index = 0; + if node['data'] + for child in @data.children + if child.key == uuid + @data.children[index].data = node['data'] + index++ + @renderSubnode(node['uri'], node['content']) + + toggleListActions: (enable = false) => + @container.find('.subnodes__item-shift').toggleClass('subnodes__item-shift--disabled', !enable) + @editor.$add.toggleClass('disabled', !enable) + @directions.find('input').prop('disabled', !enable) + + setDirty: () => + @setState 'dirty' + @trigger 'editor:dirty' + + array_move: (arr, old_index, new_index) -> + if new_index >= arr.length + k = new_index - arr.length + 1 + while k-- + arr.push(undefined) + arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); + + moveChild: (uri, steps) => + _uri = uri.to_uri(); + step = 0; + for child in @data.children + if (child.key == _uri.get_query_param('key')) + if (step+steps >= 0 && step+steps < @data.children.length) + @array_move(@data.children, step, step+steps) + return step+steps; + else + step++; + return false; + + resortNodes: () => + step = 0; + for child in @data.children + $("[data-key="+child.key+"]").css('order', step) + step++; + + getSubnodeUriKey: (key = undefined) => + keys = "" + uri = @node.uri.to_uri() + if uri.get_query_param('key') + keys += @node.uri.to_uri().get_query_param('key') + "_" + return keys + (key or @generateGuid()) + + getSubnodeKey: (composite_key) => + keys = composite_key.split('_') + return keys[keys.length - 1] + + generateGuid: () -> + result = '' + for j in [0...32] + if j == 8 || j == 12 || j == 16 || j == 20 + result = result + '-' + i = Math.floor(Math.random()*16).toString(16).toUpperCase() + result = result + i + return result + + loadRevisionByClass: (targetVersionClass) => + @loadRevision({ + type:'click', + target: $('#revisions').find(targetVersionClass).find('a').get()[0], + preventDefault: () -> {}, + }) diff --git a/djedi/static/djedi/plugins/list/js/list.js b/djedi/static/djedi/plugins/list/js/list.js new file mode 100644 index 00000000..8456d3f9 --- /dev/null +++ b/djedi/static/djedi/plugins/list/js/list.js @@ -0,0 +1,490 @@ +// Generated by CoffeeScript 1.8.0 +(function() { + var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; + + window.ListEditor = (function(_super) { + __extends(ListEditor, _super); + + function ListEditor() { + this.loadRevisionByClass = __bind(this.loadRevisionByClass, this); + this.getSubnodeKey = __bind(this.getSubnodeKey, this); + this.getSubnodeUriKey = __bind(this.getSubnodeUriKey, this); + this.resortNodes = __bind(this.resortNodes, this); + this.moveChild = __bind(this.moveChild, this); + this.setDirty = __bind(this.setDirty, this); + this.toggleListActions = __bind(this.toggleListActions, this); + this.updateSubnode = __bind(this.updateSubnode, this); + this.renderSubnode = __bind(this.renderSubnode, this); + this.updateData = __bind(this.updateData, this); + this.clearList = __bind(this.clearList, this); + this.popSubnode = __bind(this.popSubnode, this); + this.saveSubnode = __bind(this.saveSubnode, this); + this.workSaveQueue = __bind(this.workSaveQueue, this); + this.onPublish = __bind(this.onPublish, this); + this.spawnSubnode = __bind(this.spawnSubnode, this); + this.setState = __bind(this.setState, this); + this.render = __bind(this.render, this); + this.onLoad = __bind(this.onLoad, this); + this.addSubnode = __bind(this.addSubnode, this); + this.setDirection = __bind(this.setDirection, this); + return ListEditor.__super__.constructor.apply(this, arguments); + } + + ListEditor.prototype.initDataStructure = function() { + return { + direction: 'col', + children: [] + }; + }; + + ListEditor.prototype.initialize = function(config) { + var plg, _i, _len, _ref; + console.log('ListEditor.initialize', this); + ListEditor.__super__.initialize.call(this, config); + this.subnodeCss = ''; + this.editor = this; + this.subnodeIframes = []; + this.data = this.initDataStructure(); + this.saveQueue = []; + this.loading = false; + this.preventParentReload = false; + this.subnodeDirty = false; + this.doShallowSave = false; + this.container = $('#node-list'); + this.dataHolder = $('#subnode-data'); + this.directions = $('#direction-options'); + this.editor.$add_list = $('#plugin-list'); + $('#form input').unbind(); + $('#form textarea').unbind(); + $('#form select').unbind(); + this.directions.find('input').on('change', (function(_this) { + return function(e) { + return _this.setDirection(e.target.value); + }; + })(this)); + _ref = config.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plg = _ref[_i]; + if (plg !== 'list') { + $("
  • " + plg + "
  • ").appendTo(this.editor.$add_list); + } + } + this.editor.$add = $('.node-add'); + return this.editor.$add.on('click', (function(_this) { + return function(evt) { + if (!_this.subnodeDirty) { + return _this.addSubnode(_this.getSubnodeUriKey(), $(evt.target).text(), true); + } + }; + })(this)); + }; + + ListEditor.prototype.setDirection = function(dir, refreshData) { + var target; + if (refreshData == null) { + refreshData = true; + } + this.directions.find('[name="direction"]').prop('checked', false); + target = this.directions.find("[value='" + dir + "']"); + if (target.length === 1) { + target.prop('checked', true); + this.data.direction = dir; + this.updateData(refreshData); + return this.setDirty(); + } else { + return this.setDirection("col", refreshData); + } + }; + + ListEditor.prototype.addSubnode = function(key, plugin, markDirty, defaultData) { + if (defaultData == null) { + defaultData = ""; + } + return this.spawnSubnode(this.node.uri.clone({ + query: { + key: [key], + plugin: [plugin] + }, + version: "" + }).valueOf(), markDirty, defaultData); + }; + + ListEditor.prototype.onLoad = function(node) { + var codedData, entry, exception, _i, _len, _ref; + this.loading = true; + this.clearList(); + ListEditor.__super__.onLoad.call(this, node); + this.frameBias = "node/" + encodeURIComponent(encodeURIComponent(node.uri.valueOf().replace('#' + node.uri.version, ''))) + "/editor"; + try { + codedData = node.data; + _ref = codedData.children; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + entry = _ref[_i]; + this.addSubnode(this.getSubnodeUriKey(entry.key), entry.plugin, false, entry.data); + } + this.setDirection(codedData.direction, false); + } catch (_error) { + exception = _error; + this.clearList(); + this.updateData(true); + console.log("ListEditor.onLoad(), error when loading. Data invalid: ", exception); + } + return this.loading = false; + }; + + ListEditor.prototype.render = function(node) { + console.log('ListEditor.render()', node.content, this); + this.dataHolder.val(JSON.stringify(this.data)); + return ListEditor.__super__.render.call(this, node); + }; + + ListEditor.prototype.setState = function(state) { + if (state === 'draft' && this.preventParentReload || state === 'dirty' && this.loading) { + return; + } + if (state === "dirty" && this.subnodeDirty) { + this.toggleListActions(); + } + return ListEditor.__super__.setState.call(this, state); + }; + + ListEditor.prototype.spawnSubnode = function(uri, refreshValue, data) { + var classes, handle, holder, node, node_container, node_iframe, path, ref_uri, title, windowRef; + if (refreshValue == null) { + refreshValue = true; + } + if (data == null) { + data = ""; + } + console.log("ListEditor.spawnSubNode()"); + classes = 'subnodes__item'; + node_container = $("
    ").appendTo(this.container); + title = $("
    ").appendTo(node_container); + holder = $("
    ").appendTo(node_container); + title.on('click', (function(_this) { + return function(e) { + return $(e.target).parent().toggleClass('subnodes__item--closed'); + }; + })(this)); + $("
    ").appendTo(title).on('click', (function(_this) { + return function(e) { + return _this.popSubnode($(e.target).parents('.subnodes__item').attr("uri-ref")); + }; + })(this)); + handle = $("
    ").prependTo(title); + handle.find('a').on('click', (function(_this) { + return function(event) { + var newOrder; + if (_this.subnodeDirty) { + return false; + } + newOrder = false; + if ($(event.target).hasClass('subnodes__item-shift--up') || $(event.target).hasClass('icon-chevron-up')) { + newOrder = _this.moveChild(uri, -1); + } else { + newOrder = _this.moveChild(uri, 1); + } + if (newOrder !== false) { + _this.resortNodes(); + _this.updateData(true); + _this.setDirty(); + return _this.shallowSave(); + } + }; + })(this)); + node = new window.Node(uri, data, holder); + title.append("" + (node.uri.get_query_param('plugin') || 'unknown') + ""); + title.find('.subnodes__item-title__text').addClass(this.getPluginColor(node.uri.get_query_param('plugin') || 'plugin-fg-unknown')); + node_container.attr('uri-ref', node.uri.valueOf()); + node_container.attr('data-key', node.uri.get_query_param('key')); + node_iframe = new window.Plugin(node); + ref_uri = this.node.uri.clone({ + version: "" + }).valueOf(); + path = document.location.pathname.replace("node/" + (encodeURIComponent(encodeURIComponent(ref_uri))) + "/editor", ""); + path = path.replace("node/" + (encodeURIComponent(encodeURIComponent(this.node.uri))) + "/editor", ""); + node_iframe.$el.attr('src', path + ("node/" + (encodeURIComponent(encodeURIComponent(uri))) + "/editor")); + node_container.css('order', this.data.children.length); + this.subnodeIframes.push(node_iframe); + this.data.children.push({ + key: this.getSubnodeKey(node.uri.get_query_param('key')), + plugin: node.uri.get_query_param('plugin'), + data: data + }); + holder.append(node_iframe.$el); + windowRef = node_iframe.$el[0].contentWindow; + $(node_iframe.$el).on('load', (function(_this) { + return function() { + var head; + head = windowRef.$(node_iframe.$el[0]).contents().find("head"); + windowRef.$(head).append(_this.subnodeCss); + windowRef.$(windowRef.document).on('editor:state-changed', function(event, oldState, newState, node) { + if (oldState === 'dirty' && newState === 'draft') { + _this.workSaveQueue(); + return _this.updateSubnode(node.uri.to_uri().get_query_param('key'), node); + } + }); + windowRef.$(windowRef.document).on('editor:dirty', function() { + _this.subnodeDirty = true; + return _this.setDirty(); + }); + windowRef.$(windowRef.document).on('node:update', function(event, uri, node) { + return _this.updateSubnode(node.uri.to_uri().get_query_param('key'), node); + }); + return windowRef.$(windowRef.document).on('node:render', function(event, uri, content) { + return _this.renderSubnode(uri, content); + }); + }; + })(this)); + this.updateData(refreshValue); + if (refreshValue) { + return this.setDirty(); + } + }; + + ListEditor.prototype.save = function() { + var subnode_iframe, _i, _len, _ref; + this.preventParentReload = true; + _ref = this.subnodeIframes; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + subnode_iframe = _ref[_i]; + this.saveQueue.push(subnode_iframe); + } + return ListEditor.__super__.save.apply(this, arguments); + }; + + ListEditor.prototype.shallowSave = function() { + this.doShallowSave = true; + if (this.state === "dirty") { + return this.trigger('editor:save', this.node.uri); + } + }; + + ListEditor.prototype.onSave = function(node) { + ListEditor.__super__.onSave.call(this, node); + if (!this.doShallowSave) { + return this.workSaveQueue(); + } else { + return this.doShallowSave = false; + } + }; + + ListEditor.prototype.onPublish = function() { + ListEditor.__super__.onPublish.apply(this, arguments); + this.loadRevisionByClass('.published'); + return this.setState('published'); + }; + + ListEditor.prototype.workSaveQueue = function() { + console.log("ListEditor.workSaveQueue()", this.saveQueue.length); + if (this.saveQueue.length > 0) { + return this.saveSubnode(this.saveQueue.pop()); + } else { + this.preventParentReload = false; + this.loadRevisionByClass('.draft'); + this.setState('draft'); + this.subnodeDirty = false; + return this.toggleListActions(true); + } + }; + + ListEditor.prototype.saveSubnode = function(plugin) { + var windowRef; + windowRef = plugin.$el[0].contentWindow; + if (windowRef && windowRef.editor && windowRef.editor.state !== 'dirty') { + return this.workSaveQueue(); + } else if (windowRef && windowRef.editor) { + return windowRef.editor.save(); + } + }; + + ListEditor.prototype.popSubnode = function(uri) { + var targetKey, targetUri; + console.log("ListEditor.popSubnode()"); + targetUri = uri; + targetKey = this.getSubnodeKey(targetUri.to_uri().get_query_param('key')); + this.subnodeIframes = this.subnodeIframes.filter((function(_this) { + return function(value) { + if (value.uri.valueOf() !== targetUri) { + return true; + } + value.close(); + _this.container.find('[uri-ref="' + targetUri + '"]').remove(); + return false; + }; + })(this)); + this.data.children = this.data.children.filter(function(value) { + if (value.key !== targetKey) { + return true; + } + return false; + }); + this.setDirty(); + return this.updateData(true); + }; + + ListEditor.prototype.clearList = function() { + this.container.empty(); + this.subnodeIframes = []; + return this.data = this.initDataStructure(); + }; + + ListEditor.prototype.updateData = function(reRender) { + var collection; + if (reRender == null) { + reRender = false; + } + collection = JSON.stringify(this.data); + this.dataHolder.val(collection); + this.dataHolder.change(); + this.node.data = collection; + if (reRender) { + return this.api.render("list", { + data: collection + }, (function(_this) { + return function(response) { + var contentSet; + contentSet = $(response)[0]; + _this.node.content = contentSet; + return _this.editor.triggerRender(_this.node.content); + }; + })(this)); + } + }; + + ListEditor.prototype.renderSubnode = function(uri, content) { + var key, newContent; + console.log("ListEditor.renderSubnode()"); + key = this.getSubnodeKey(decodeURIComponent(uri.to_uri().get_query_param('key'))); + newContent = $(this.node.content).find('#' + key).html(content).end()[0]; + this.updateData(false); + this.node.content = newContent; + return this.editor.triggerRender(newContent); + }; + + ListEditor.prototype.updateSubnode = function(uuid, node, norender) { + var child, index, _i, _len, _ref; + if (norender == null) { + norender = false; + } + console.log("ListEditor.updateSubnode()", uuid); + index = 0; + if (node['data']) { + _ref = this.data.children; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + if (child.key === uuid) { + this.data.children[index].data = node['data']; + } + index++; + } + } + return this.renderSubnode(node['uri'], node['content']); + }; + + ListEditor.prototype.toggleListActions = function(enable) { + if (enable == null) { + enable = false; + } + this.container.find('.subnodes__item-shift').toggleClass('subnodes__item-shift--disabled', !enable); + this.editor.$add.toggleClass('disabled', !enable); + return this.directions.find('input').prop('disabled', !enable); + }; + + ListEditor.prototype.setDirty = function() { + this.setState('dirty'); + return this.trigger('editor:dirty'); + }; + + ListEditor.prototype.array_move = function(arr, old_index, new_index) { + var k; + if (new_index >= arr.length) { + k = new_index - arr.length + 1; + while (k--) { + arr.push(void 0); + } + } + return arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); + }; + + ListEditor.prototype.moveChild = function(uri, steps) { + var child, step, _i, _len, _ref, _uri; + _uri = uri.to_uri(); + step = 0; + _ref = this.data.children; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + if (child.key === _uri.get_query_param('key')) { + if (step + steps >= 0 && step + steps < this.data.children.length) { + this.array_move(this.data.children, step, step + steps); + return step + steps; + } + } else { + step++; + } + } + return false; + }; + + ListEditor.prototype.resortNodes = function() { + var child, step, _i, _len, _ref, _results; + step = 0; + _ref = this.data.children; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + $("[data-key=" + child.key + "]").css('order', step); + _results.push(step++); + } + return _results; + }; + + ListEditor.prototype.getSubnodeUriKey = function(key) { + var keys, uri; + if (key == null) { + key = void 0; + } + keys = ""; + uri = this.node.uri.to_uri(); + if (uri.get_query_param('key')) { + keys += this.node.uri.to_uri().get_query_param('key') + "_"; + } + return keys + (key || this.generateGuid()); + }; + + ListEditor.prototype.getSubnodeKey = function(composite_key) { + var keys; + keys = composite_key.split('_'); + return keys[keys.length - 1]; + }; + + ListEditor.prototype.generateGuid = function() { + var i, j, result, _i; + result = ''; + for (j = _i = 0; _i < 32; j = ++_i) { + if (j === 8 || j === 12 || j === 16 || j === 20) { + result = result + '-'; + } + i = Math.floor(Math.random() * 16).toString(16).toUpperCase(); + result = result + i; + } + return result; + }; + + ListEditor.prototype.loadRevisionByClass = function(targetVersionClass) { + return this.loadRevision({ + type: 'click', + target: $('#revisions').find(targetVersionClass).find('a').get()[0], + preventDefault: function() { + return {}; + } + }); + }; + + return ListEditor; + + })(window.Editor); + +}).call(this); diff --git a/djedi/static/djedi/themes/base/theme.less b/djedi/static/djedi/themes/base/theme.less index 3379ba04..58de4ecd 100644 --- a/djedi/static/djedi/themes/base/theme.less +++ b/djedi/static/djedi/themes/base/theme.less @@ -66,8 +66,27 @@ body { background-color: @body-bg; color: @text-color; .antialiased(); + display: block; //fallback + display: flex; + flex-direction: column; + & .tab-content { + flex: 1; + display: flex; + flex-direction: column; + + .tab-pane { + flex: 1; + height: auto; + max-height: none; + + #editor-iframe { + height: 100%; + } + } + } &.embedded { + overflow: hidden; &.closed { @@ -408,7 +427,27 @@ body { } body.editor { - + .dropdown-menu { + background-color: rgba(0, 0, 0, 0.75); + border-color: #666; + text-shadow: none; + font-size: 9px; + min-width: 80px; + overflow: auto; + max-height: 300px; + margin-right: 45px; + + > li { + > a { + font-weight: bold; + color: #ffad3c; + &:hover { + color: #fff; + background-color: #222; + } + } + } + } header { .navbar { background: @editor-title-bg; diff --git a/djedi/static/djedi/themes/darth/theme.css b/djedi/static/djedi/themes/darth/theme.css index a4d20a20..2dea21c3 100644 --- a/djedi/static/djedi/themes/darth/theme.css +++ b/djedi/static/djedi/themes/darth/theme.css @@ -5343,6 +5343,22 @@ body { -ms-font-smoothing: antialiased; -o-font-smoothing: antialiased; font-smoothing: antialiased; + display: block; + display: flex; + flex-direction: column; +} +body .tab-content { + flex: 1; + display: flex; + flex-direction: column; +} +body .tab-content .tab-pane { + flex: 1; + height: auto; + max-height: none; +} +body .tab-content .tab-pane #editor-iframe { + height: 100%; } body.embedded { overflow: hidden; @@ -5687,6 +5703,24 @@ body.open .navbar-brand > a > i.dc-djedi-icon { font-style: normal; cursor: pointer; } +body.editor .dropdown-menu { + background-color: rgba(0, 0, 0, 0.75); + border-color: #666; + text-shadow: none; + font-size: 9px; + min-width: 80px; + overflow: auto; + max-height: 300px; + margin-right: 45px; +} +body.editor .dropdown-menu > li > a { + font-weight: bold; + color: #ffad3c; +} +body.editor .dropdown-menu > li > a:hover { + color: #fff; + background-color: #222; +} body.editor header .navbar { background: rgba(0, 0, 0, 0.25); border-bottom: 1px solid #1b1b1b; diff --git a/djedi/static/djedi/themes/luke/theme.css b/djedi/static/djedi/themes/luke/theme.css index 4ccae96c..2dd3617d 100644 --- a/djedi/static/djedi/themes/luke/theme.css +++ b/djedi/static/djedi/themes/luke/theme.css @@ -5343,6 +5343,22 @@ body { -ms-font-smoothing: antialiased; -o-font-smoothing: antialiased; font-smoothing: antialiased; + display: block; + display: flex; + flex-direction: column; +} +body .tab-content { + flex: 1; + display: flex; + flex-direction: column; +} +body .tab-content .tab-pane { + flex: 1; + height: auto; + max-height: none; +} +body .tab-content .tab-pane #editor-iframe { + height: 100%; } body.embedded { overflow: hidden; @@ -5687,6 +5703,24 @@ body.open .navbar-brand > a > i.dc-djedi-icon { font-style: normal; cursor: pointer; } +body.editor .dropdown-menu { + background-color: rgba(0, 0, 0, 0.75); + border-color: #666; + text-shadow: none; + font-size: 9px; + min-width: 80px; + overflow: auto; + max-height: 300px; + margin-right: 45px; +} +body.editor .dropdown-menu > li > a { + font-weight: bold; + color: #ffad3c; +} +body.editor .dropdown-menu > li > a:hover { + color: #fff; + background-color: #222; +} body.editor header .navbar { background: #fff; border-bottom: 1px solid #e1e1e1; diff --git a/djedi/templates/djedi/cms/cms.html b/djedi/templates/djedi/cms/cms.html index 02973457..309dc73c 100644 --- a/djedi/templates/djedi/cms/cms.html +++ b/djedi/templates/djedi/cms/cms.html @@ -44,5 +44,6 @@ + diff --git a/djedi/templates/djedi/plugins/base/editor.html b/djedi/templates/djedi/plugins/base/editor.html index ebcceadf..bcccbf03 100644 --- a/djedi/templates/djedi/plugins/base/editor.html +++ b/djedi/templates/djedi/plugins/base/editor.html @@ -42,7 +42,7 @@ diff --git a/djedi/templates/djedi/plugins/img/editor.html b/djedi/templates/djedi/plugins/img/editor.html index b9956884..c243d960 100644 --- a/djedi/templates/djedi/plugins/img/editor.html +++ b/djedi/templates/djedi/plugins/img/editor.html @@ -59,6 +59,7 @@ uri: '{{ uri }}', field: '#image', preview: '#preview', + enable_crop_preview: {% if disable_crop_preview %}false{% else %}true{% endif %}, dropzone: '#dropzone' }); diff --git a/djedi/templates/djedi/plugins/list/editor.html b/djedi/templates/djedi/plugins/list/editor.html new file mode 100644 index 00000000..18e3da2a --- /dev/null +++ b/djedi/templates/djedi/plugins/list/editor.html @@ -0,0 +1,39 @@ +{% extends 'djedi/plugins/base/editor.html' %} +{% load static %} +{% block editor %} +
    +
    + +
    +
    +
    + + +
    +
    +
    +{% endblock editor %} + +{% block plugin_style %} + +{% endblock %} + +{% block plugin_script %} + + + +{% endblock plugin_script %} diff --git a/djedi/tests/test_admin.py b/djedi/tests/test_admin.py index ff0ebf18..c1ea595a 100644 --- a/djedi/tests/test_admin.py +++ b/djedi/tests/test_admin.py @@ -1,7 +1,7 @@ from unittest import skip from django.urls import reverse -from django.utils.encoding import smart_text +from django.utils.encoding import smart_str import cio.conf from djedi.tests.base import ClientTest @@ -11,10 +11,10 @@ class PanelTest(ClientTest): def test_embed(self): url = reverse("index") response = self.client.get(url) - self.assertIn("Djedi Test", smart_text(response.content)) - self.assertIn("window.DJEDI_NODES", smart_text(response.content)) - self.assertIn("i18n://sv-se@foo/bar.txt", smart_text(response.content)) - self.assertIn("", smart_text(response.content).lower()) + self.assertIn("Djedi Test", smart_str(response.content)) + self.assertIn("window.DJEDI_NODES", smart_str(response.content)) + self.assertIn("i18n://sv-se@foo/bar.txt", smart_str(response.content)) + self.assertIn("", smart_str(response.content).lower()) def test_middleware(self): with self.settings( @@ -27,14 +27,14 @@ def test_middleware(self): ): url = reverse("index") response = self.client.get(url) - self.assertNotIn("window.DJEDI_NODES", smart_text(response.content)) + self.assertNotIn("window.DJEDI_NODES", smart_str(response.content)) def test_cms(self): url = reverse("admin:djedi:cms") response = self.client.get(url) - self.assertIn("djedi cms", smart_text(response.content)) - self.assertNotIn("document.domain", smart_text(response.content)) - self.assertNotIn("None", smart_text(response.content)) + self.assertIn("djedi cms", smart_str(response.content)) + self.assertNotIn("document.domain", smart_str(response.content)) + self.assertNotIn("None", smart_str(response.content)) with cio.conf.settings(XSS_DOMAIN="foobar.se"): response = self.client.get(url) @@ -51,7 +51,7 @@ def test_django_admin(self): # pragma: no cover url = reverse("admin:index") response = self.client.get(url) cms_url = reverse("admin:djedi:cms") - self.assertIn('CMS' % cms_url, smart_text(response.content)) + self.assertIn('CMS' % cms_url, smart_str(response.content)) # Rollback patch AdminLogNode.render = _render diff --git a/djedi/tests/test_rest.py b/djedi/tests/test_rest.py index 358120ac..97ff858d 100644 --- a/djedi/tests/test_rest.py +++ b/djedi/tests/test_rest.py @@ -1,11 +1,11 @@ import os +from urllib.parse import quote import simplejson as json from django.core.files import File from django.test import Client from django.urls import reverse -from django.utils.encoding import smart_text -from django.utils.http import urlquote +from django.utils.encoding import smart_str import cio import cio.conf @@ -51,9 +51,7 @@ def test_permissions(self): class PrivateRestTest(ClientTest): def get_api_url(self, url_name, uri): - return reverse( - "admin:djedi:" + url_name, args=[urlquote(urlquote(uri, ""), "")] - ) + return reverse("admin:djedi:" + url_name, args=[quote(quote(uri, ""), "")]) def get(self, url_name, uri): url = self.get_api_url(url_name, uri) @@ -171,7 +169,7 @@ def test_delete(self): response = self.delete("api", node.uri) self.assertEqual(response.status_code, 200) - self.assertEqual(smart_text(response.content), "") + self.assertEqual(smart_str(response.content), "") with self.assertRaises(NodeDoesNotExist): storage.get("i18n://sv-se@page/title") @@ -220,7 +218,7 @@ def test_render(self): response = self.post("api.render", "md", {"data": "# Djedi"}) assert response.status_code == 200 - self.assertRenderedMarkdown(smart_text(response.content), "# Djedi") + self.assertRenderedMarkdown(smart_str(response.content), "# Djedi") response = self.post( "api.render", @@ -234,7 +232,7 @@ def test_render(self): self.assertEqual(response.status_code, 200) self.assertEqual( - smart_text(response.content), + smart_str(response.content), '', ) @@ -252,7 +250,9 @@ def test_editor(self): assert set(response.context_data.keys()) == { "THEME", "VERSION", + "PLUGINS", "uri", + "plugin", "forms", } assert "HTML" in response.context_data["forms"] @@ -266,7 +266,13 @@ def test_editor(self): ) else: - assert set(response.context_data.keys()) == {"THEME", "VERSION", "uri"} + assert set(response.context_data.keys()) == { + "THEME", + "VERSION", + "PLUGINS", + "uri", + "plugin", + } self.assertNotIn(b"document.domain", response.content) @@ -335,6 +341,289 @@ def test_upload(self): response = self.post("api", "i18n://sv-se@header/logo.img", form) self.assertEqual(response.status_code, 200) + def test_save_nested_content(self): + data = { + "direction": "col", + "children": [ + { + "key": "abc123", + "plugin": "md", + "data": "# One banana", + }, + { + "key": "321cba", + "plugin": "txt", + "data": "Bananas", + }, + ], + } + listnode = cio.set("sv-se@page/apa.list", json.dumps(data)) + text_node_uri = listnode.uri.clone(query={"key": ["321cba"], "plugin": ["txt"]}) + self.assertEqual( + listnode.content, + ( + '
      ' + '
    • ' + "

      One banana

      " + "
    • " + '
    • ' + "Bananas" + "
    • " + "
    " + ), + ) + text_node = cio.load(text_node_uri) + self.assertEqual(text_node["data"], "Bananas") + self.assertEqual(text_node["content"], "Bananas") + + md_node = cio.load( + listnode.uri.clone(query={"key": ["abc123"], "plugin": ["md"]}) + ) + self.assertEqual(md_node["content"], "

    One banana

    ") + self.assertEqual(md_node["data"], "# One banana") + + form = { + "data[width]": "64", + "data[height]": "64", + "data[crop]": "64,64,128,128", + "data[id]": "vw", + "data[class]": "year-53", + "data[alt]": "Zwitter", + "meta[comment]": "VW", + } + response = self.post( + "api", "i18n://sv-se@page/apa.list?key=imagekey&plugin=img", form + ) + self.assertEqual(response.status_code, 200) + + # TODO: Test getting default data + # img_node_in_list = cio.load('sv-se@page/apa.list?key=idontexist&plugin=img') + # img_node = cio.load('sv-se@page/monkeydo.img') + # self.assertEqual(img_node_in_list['data'], img_node['data']) + # self.assertEqual(img_node_in_list['content'], img_node['content']) + + # Test setting new subnode data + cio.set("sv-se@page/apa.list?key=newkey&plugin=md", "# Banan") + node_data = cio.load("sv-se@page/apa.list?key=newkey&plugin=md") + self.assertEqual(node_data["content"], "

    Banan

    ") + self.assertEqual(node_data["data"], "# Banan") + + # Test setting existing subnode data + cio.set("sv-se@page/apa.list?key=abc123&plugin=md", "# Two Bananas") + node_data = cio.load("sv-se@page/apa.list?key=abc123&plugin=md") + + self.assertEqual(node_data["content"], "

    Two Bananas

    ") + self.assertEqual(node_data["data"], "# Two Bananas") + + # Test setting it multiple times + cio.set( + "sv-se@page/apa.list#draft", + json.dumps( + { + "direction": "col", + "children": [ + { + "key": "abc123", + "plugin": "md", + "data": "# One banana", + } + ], + } + ), + ) + cio.set("sv-se@page/apa.list?key=abc123&plugin=md", "# No bananas") + cio.set("sv-se@page/apa.list?key=abc123&plugin=md", "# Many bananas") + cio.set("sv-se@page/apa.list?key=abc123&plugin=md", "# Many bananas") + node_data = cio.load("sv-se@page/apa.list?key=abc123&plugin=md") + parent_node = cio.load("sv-se@page/apa.list") + self.assertEqual(node_data["content"], "

    Many bananas

    ") + self.assertEqual(node_data["data"], "# Many bananas") + self.assertDictEqual( + parent_node["data"], + { + "direction": "col", + "children": [ + { + "key": "abc123", + "plugin": "md", + "data": "# Many bananas", + } + ], + }, + ) + + # Test nested list + cio.set( + "sv-se@page/apa.list", + json.dumps( + { + "direction": "col", + "children": [ + { + "key": "abc123", + "plugin": "md", + "data": "# One banana", + } + ], + } + ), + ) + cio.set( + "sv-se@page/apa.list?key=321cba&plugin=list", + json.dumps( + { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "# My banana", + } + ], + } + ), + ) + node_data = cio.load("sv-se@page/apa.list?key=abc123&plugin=md") + list_node = cio.load("sv-se@page/apa.list?key=321cba&plugin=list") + parent_node = cio.load("sv-se@page/apa.list") + child_node = cio.load("sv-se@page/apa.list?key=321cba_betterkey&plugin=md") + self.assertEqual(node_data["content"], "

    One banana

    ") + self.assertEqual(node_data["data"], "# One banana") + self.assertDictEqual( + parent_node["data"], + { + "direction": "col", + "children": [ + { + "key": "abc123", + "plugin": "md", + "data": "# One banana", + }, + { + "key": "321cba", + "plugin": "list", + "data": json.dumps( + { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "# My banana", + } + ], + } + ), + }, + ], + }, + ) + self.assertDictEqual( + list_node["data"], + { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "# My banana", + } + ], + }, + ) + self.assertEqual(child_node["data"], "# My banana") + self.assertEqual(child_node["content"], "

    My banana

    ") + + cio.set("sv-se@page/apa.list?key=321cba_betterkey&plugin=md", "# Not yours") + deep_node = cio.load("sv-se@page/apa.list?key=321cba_betterkey&plugin=md") + list_node = cio.load("sv-se@page/apa.list?key=321cba&plugin=list") + self.assertEqual(deep_node["data"], "# Not yours") + self.assertEqual(deep_node["content"], "

    Not yours

    ") + self.assertEqual( + list_node["data"], + { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "# Not yours", + } + ], + }, + ) + + empty_subnode = cio.set( + "sv-se@page/apa.list?key=321cba_betterkey&plugin=md", "" + ) + list_node = cio.load("sv-se@page/apa.list?key=321cba&plugin=list") + self.assertIsNone(empty_subnode.content) + self.assertEqual( + list_node["data"], + { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "", + } + ], + }, + ) + + cio.set( + "sv-se@page/render.list", json.dumps({"direction": "col", "children": []}) + ) + response = self.post("api.render", "md", {"data": "# Djedi"}) + assert response.status_code == 200 + self.assertRenderedMarkdown(smart_text(response.content), "# Djedi") + + data = { + "direction": "col", + "children": [ + { + "key": "betterkey", + "plugin": "md", + "data": "# Not yours", + } + ], + } + + response = self.post("api.render", "list", {"data": json.dumps(data)}) + assert response.status_code == 200 + self.assertEqual( + response.content, + b'
      ' + b'
    • ' + b"

      Not yours

      " + b"
    • " + b"
    ", + ) + + empty_subnode = cio.set( + "sv-se@page/listthatdoesntexist.list?key=321cba&plugin=md", "# Hej" + ) + self.assertEqual(empty_subnode.content, "

    Hej

    ") + + response = self.get("cms.editor", "sv-se@page/context-test.list?plugin=img") + self.assertEqual(response.status_code, 200) + assert set(response.context_data.keys()) == { + "THEME", + "VERSION", + "PLUGINS", + "uri", + "plugin", + "forms", + } + assert "HTML" in response.context_data["forms"] + assert isinstance(response.context_data["forms"]["HTML"], BaseEditorForm) + + self.assertListEqual( + ["data__id", "data__alt", "data__class"], + list(response.context_data["forms"]["HTML"].fields.keys()), + ) + class PublicRestTest(ClientTest): def test_api_root_not_found(self): @@ -345,7 +634,7 @@ def test_api_root_not_found(self): def test_embed(self): url = reverse("admin:djedi:rest:embed") response = self.client.get(url) - html = smart_text(response.content) + html = smart_str(response.content) self.assertIn('iframe id="djedi-cms"', html) cms_url = "http://testserver" + reverse("admin:djedi:cms") diff --git a/djedi/tests/urls.py b/djedi/tests/urls.py index 57ce6d36..0b13f504 100644 --- a/djedi/tests/urls.py +++ b/djedi/tests/urls.py @@ -1,11 +1,11 @@ -from django.conf.urls import include, url from django.contrib import admin from django.shortcuts import render +from django.urls import include, re_path admin.autodiscover() urlpatterns = [ - url(r"^$", lambda r: render(r, "index.html"), name="index"), - url(r"^adm1n/", admin.site.urls), - url(r"^djed1/", include("djedi.urls", namespace="admin")), + re_path(r"^$", lambda r: render(r, "index.html"), name="index"), + re_path(r"^adm1n/", admin.site.urls), + re_path(r"^djed1/", include("djedi.urls", namespace="admin")), ] diff --git a/djedi/urls.py b/djedi/urls.py index b3eb4af9..2bbba7d8 100644 --- a/djedi/urls.py +++ b/djedi/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import include -from django.urls import path +from django.urls import include, path app_name = "djedi" diff --git a/docs/plugins.rst b/docs/plugins.rst index 5b5113ed..62702259 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -1,7 +1,7 @@ .. _plugins: Plugins -======= +======== Djedi CMS plugins handles content serialization and rendering. Each plugin has its own extension which is referred to in the request URI. diff --git a/example/example/settings.py b/example/example/settings.py index d5fc5d21..d56e5db7 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -16,72 +16,74 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'jf_&sn#&5gb7n510ji#9m7^@@-w@g%f(j0y=@=)pmvpc@4ia0)' +SECRET_KEY = "jf_&sn#&5gb7n510ji#9m7^@@-w@g%f(j0y=@=)pmvpc@4ia0)" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'corsheaders', - 'djedi', - 'example', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "djedi", + "example", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'djedi.middleware.translation.DjediTranslationMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "djedi.middleware.translation.DjediTranslationMiddleware", ] -ROOT_URLCONF = 'example.urls' +ROOT_URLCONF = "example.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'example.wsgi.application' +WSGI_APPLICATION = "example.wsgi.application" # Database # https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -95,9 +97,9 @@ # Internationalization # https://docs.djangoproject.com/en/dev/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -109,31 +111,29 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/dev/howto/static-files/ -STATIC_URL = '/static/' -MEDIA_URL = '/media/' +STATIC_URL = "/static/" +MEDIA_URL = "/media/" -STATIC_ROOT = '/static/' -MEDIA_ROOT = '/media/' +STATIC_ROOT = "/static/" +MEDIA_ROOT = "/media/" # To test if collectstatic with ManifestStaticFilesStorage works, set `DEBUG = # False` and run: # docker-compose exec django python manage.py collectstatic --no-input # DEBUG = False -STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" # CORS # https://github.com/OttoYiu/django-cors-headers -CORS_ORIGIN_WHITELIST = ( - 'localhost:3000', -) +CORS_ORIGIN_WHITELIST = ("localhost:3000",) CORS_ALLOW_CREDENTIALS = True # Djedi # https://djedi-cms.org/settings.html -DJEDI_XSS_DOMAIN = 'localhost' +DJEDI_XSS_DOMAIN = "localhost" # env DJEDI_THEME=luke docker-compose up -d django -DJEDI_THEME = os.environ.get('DJEDI_THEME') or None +DJEDI_THEME = os.environ.get("DJEDI_THEME") or None diff --git a/example/example/templates/index.html b/example/example/templates/index.html index b59f9093..a19cbd7f 100644 --- a/example/example/templates/index.html +++ b/example/example/templates/index.html @@ -14,6 +14,26 @@ max-width: 100%; height: auto; } + + .djedi-list { + list-style-type: none; + margin:0; + padding: 0; + display: flex; + } + + .djedi-list--col { + flex-direction: column; + } + .djedi-list--ucol { + flex-direction: column-reverse; + } + .djedi-list--row { + flex-direction: row; + } + .djedi-list--urow { + flex-direction: row-reverse; + } @@ -27,6 +47,7 @@

    {% node 'page/title.txt' default='Djedi' %}

    {% node 'page/image.img' %} {% node 'page/image2.img' %} +{% node 'page/listnode.list' %}
    {% if not request.user.is_authenticated %} diff --git a/setup.py b/setup.py index a2216723..af10b03e 100644 --- a/setup.py +++ b/setup.py @@ -58,10 +58,11 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", ], diff --git a/tox.ini b/tox.ini index bfa47191..4df2cc32 100644 --- a/tox.ini +++ b/tox.ini @@ -4,15 +4,16 @@ # and then run "tox" from this directory. [tox] -envlist = py36-django{ 22, 30, 31, 32 }, - py37-django{ 22, 30, 31, 32 }, - py38-django{ 22, 30, 31, 32 }, - py39-django{ 22, 30, 31, 32 } +envlist = py38-django{ 32, 40, 41 }, + py39-django{ 32, 40, 41 }, + py310-django{ 32, 40, 41 }, + py311-django{ 32, 40, 41 }, + py312-django{ 32, 40, 41 }, [testenv] passenv = COVERAGE_FILE -whitelist_externals = make +allowlist_externals = make commands = make test install_command = pip install --pre {opts} {packages} deps = six @@ -20,27 +21,28 @@ deps = six markdown<=3.3 django-discover-runner coverage - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 + django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 [testenv:lcov] passenv = COVERAGE_FILE -whitelist_externals = make +allowlist_externals = make commands = make coverage-lcov install_command = pip install --pre {opts} {packages} deps = coverage [testenv:coverage] skip_install = true -basepython = python3.9 +basepython = python3.11 passenv = COVERAGE_FILE +allowlist_externals = make commands = make coverage deps = coverage [testenv:lint] skip_install = true -basepython = python3.9 +basepython = python3.11 +allowlist_externals = make commands = make lint deps = flake8