1# Copyright (c) 2008 Joost Cassee
2# Licensed under the terms of the MIT License (see LICENSE.txt)
3
4"""
5This TinyMCE widget was copied and extended from this code by John D'Agostino:
6http://code.djangoproject.com/wiki/CustomWidgetsTinyMCE
7"""
8from collections import OrderedDict
9import json
10from pathlib import Path
11import warnings
12
13from django import forms
14from django.conf import settings
15from django.contrib.admin import widgets as admin_widgets
16from django.forms.utils import flatatt
17from django.urls import reverse
18from django.utils.html import escape
19from django.utils.safestring import mark_safe
20from django.utils.translation import get_language, gettext as _, to_locale
21
22import tinymce.settings
23
24
25class TinyMCE(forms.Textarea):
26    """
27    TinyMCE widget. Set settings.TINYMCE_JS_URL to set the location of the
28    javascript file. Default is "STATIC_URL + 'tinymce/tinymce.min.js'".
29    You can customize the configuration with the mce_attrs argument to the
30    constructor.
31
32    In addition to the standard configuration you can set the
33    'content_language' parameter. It takes the value of the 'language'
34    parameter by default.
35
36    In addition to the default settings from settings.TINYMCE_DEFAULT_CONFIG,
37    this widget sets the 'language', 'directionality' and
38    'spellchecker_languages' parameters by default. The first is derived from
39    the current Django language, the others from the 'content_language'
40    parameter.
41    """
42
43    def __init__(self, content_language=None, attrs=None, mce_attrs=None):
44        super().__init__(attrs)
45        mce_attrs = mce_attrs or {}
46        self.mce_attrs = mce_attrs
47        if "mode" not in self.mce_attrs:
48            self.mce_attrs["mode"] = "exact"
49        self.mce_attrs["strict_loading_mode"] = 1
50        self.content_language = content_language
51
52    def use_required_attribute(self, *args):
53        # The html required attribute may disturb client-side browser validation.
54        return False
55
56    def get_mce_config(self, attrs):
57        mce_config = tinymce.settings.DEFAULT_CONFIG.copy()
58        if "language" not in mce_config:
59            mce_config["language"] = get_language_from_django()
60        if mce_config["language"] == "en_US":
61            del mce_config["language"]
62        else:
63            mce_config["language"] = match_language_with_tinymce(mce_config["language"])
64        mce_config.update(
65            get_language_config(self.content_language or mce_config.get("language", "en_US"))
66        )
67        if tinymce.settings.USE_FILEBROWSER:
68            mce_config["file_picker_callback"] = "djangoFileBrowser"
69        mce_config.update(self.mce_attrs)
70        if mce_config["mode"] == "exact":
71            mce_config["elements"] = attrs["id"]
72        return mce_config
73
74    def render(self, name, value, attrs=None, renderer=None):
75        if value is None:
76            value = ""
77        final_attrs = self.build_attrs(self.attrs, attrs)
78        final_attrs["name"] = name
79        if final_attrs.get("class", None) is None:
80            final_attrs["class"] = "tinymce"
81        else:
82            final_attrs["class"] = " ".join(final_attrs["class"].split(" ") + ["tinymce"])
83        assert "id" in final_attrs, "TinyMCE widget attributes must contain 'id'"
84        mce_config = self.get_mce_config(final_attrs)
85        mce_json = json.dumps(mce_config)
86        if tinymce.settings.USE_COMPRESSOR:
87            compressor_config = {
88                "plugins": mce_config.get("plugins", ""),
89                "themes": mce_config.get("theme", "advanced"),
90                "languages": mce_config.get("language", ""),
91                "diskcache": True,
92                "debug": False,
93            }
94            final_attrs["data-mce-gz-conf"] = json.dumps(compressor_config)
95        final_attrs["data-mce-conf"] = mce_json
96        html = [f"<textarea{flatatt(final_attrs)}>{escape(value)}</textarea>"]
97        return mark_safe("\n".join(html))
98
99    def _media(self):
100        css = None
101        if tinymce.settings.USE_COMPRESSOR:
102            js = [reverse("tinymce-compressor")]
103        else:
104            js = [tinymce.settings.JS_URL]
105        if tinymce.settings.USE_FILEBROWSER:
106            js.append(reverse("tinymce-filebrowser"))
107        if tinymce.settings.USE_EXTRA_MEDIA:
108            if "js" in tinymce.settings.USE_EXTRA_MEDIA:
109                js += tinymce.settings.USE_EXTRA_MEDIA["js"]
110
111            if "css" in tinymce.settings.USE_EXTRA_MEDIA:
112                css = tinymce.settings.USE_EXTRA_MEDIA["css"]
113        js.append("django_tinymce/init_tinymce.js")
114        return forms.Media(css=css, js=js)
115
116    media = property(_media)
117
118
119class AdminTinyMCE(TinyMCE, admin_widgets.AdminTextareaWidget):
120    pass
121
122
123def get_language_from_django():
124    language = get_language()
125    language = to_locale(language) if language is not None else "en_US"
126    return language
127
128
129def match_language_with_tinymce(lang):
130    """
131    Language codes in TinyMCE are inconsistent. E.g. Hebrew is he_IL.js, while
132    Danish is da.js. So we apply some heuristic to find a language code
133    with an existing TinyMCE translation file.
134    """
135    if lang.startswith("en"):
136        return lang
137    # Read tinymce langs from tinymce/static/tinymce/langs/
138    tiny_lang_dir = Path(__file__).parent / "static" / "tinymce" / "langs"
139    tiny_langs = [file_.stem for file_ in tiny_lang_dir.iterdir() if file_.suffix == ".js"]
140    if lang in tiny_langs:
141        return lang
142    if lang[:2] in tiny_langs:
143        return lang[:2]
144    two_letter_map = {lg[:2]: lg for lg in tiny_langs}
145    if lang[:2] in two_letter_map:
146        return two_letter_map[lang[:2]]
147    warnings.warn(f"No TinyMCE language found for '{lang}', defaulting to 'en_US'", RuntimeWarning)
148    return "en_US"
149
150
151def get_language_config(content_language):
152    content_language = content_language[:2]
153
154    config = {}
155    lang_names = OrderedDict()
156    for lang, name in settings.LANGUAGES:
157        if lang[:2] not in lang_names:
158            lang_names[lang[:2]] = []
159        lang_names[lang[:2]].append(_(name))
160    sp_langs = []
161    for lang, names in lang_names.items():
162        if lang == content_language:
163            default = "+"
164        else:
165            default = ""
166        sp_langs.append(f'{default}{" / ".join(names)}={lang}')
167
168    config["spellchecker_languages"] = ",".join(sp_langs)
169
170    if content_language in settings.LANGUAGES_BIDI:
171        config["directionality"] = "rtl"
172    else:
173        config["directionality"] = "ltr"
174
175    if tinymce.settings.USE_SPELLCHECKER:
176        config["spellchecker_rpc_url"] = reverse("tinymce-spellcheck")
177
178    return config
179