From 96bd9c8bd44fbbd7130bf309a928367847260305 Mon Sep 17 00:00:00 2001 From: Joseph Nuthalapati Date: Sun, 5 May 2024 16:18:12 +0530 Subject: [PATCH] tiddlywiki: Add new app TiddlyWiki uses almost the same Apache configuration as Feather Wiki, with one difference - disabling gzip for the `HEAD` request. The FreedomBox app for TiddlyWiki is identical to Feather Wiki in every other aspect. - Proxy download through freedombox.org. This serves two purposes: 1. Upstream's website cannot track the IP addresses of FreedomBox users. 2. We can update the versions of the empty quine files without making code changes in FreedomBox. [sunil] - Update description to correct the list of users who can access the app. - Update logo to adhere to the logo guidelines. - Minor styling fix. - Update the copyright on the logo based on information from upstream git repository. Signed-off-by: Joseph Nuthalapati Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- debian/copyright | 7 + plinth/modules/tiddlywiki/__init__.py | 126 ++++++++++++++ .../conf-available/tiddlywiki-freedombox.conf | 26 +++ .../freedombox/modules-enabled/tiddlywiki | 1 + plinth/modules/tiddlywiki/forms.py | 37 +++++ plinth/modules/tiddlywiki/manifest.py | 16 ++ plinth/modules/tiddlywiki/privileged.py | 92 ++++++++++ .../tiddlywiki/static/icons/tiddlywiki.png | Bin 0 -> 6109 bytes .../tiddlywiki/static/icons/tiddlywiki.svg | 65 ++++++++ .../templates/tiddlywiki_configure.html | 60 +++++++ .../templates/tiddlywiki_delete.html | 37 +++++ .../templates/tiddlywiki_upload_file.html | 23 +++ plinth/modules/tiddlywiki/tests/__init__.py | 0 .../tiddlywiki/tests/data/dummy_wiki.html | 9 + .../tiddlywiki/tests/test_functional.py | 107 ++++++++++++ plinth/modules/tiddlywiki/urls.py | 20 +++ plinth/modules/tiddlywiki/views.py | 157 ++++++++++++++++++ 17 files changed, 783 insertions(+) create mode 100644 plinth/modules/tiddlywiki/__init__.py create mode 100644 plinth/modules/tiddlywiki/data/usr/share/freedombox/etc/apache2/conf-available/tiddlywiki-freedombox.conf create mode 100644 plinth/modules/tiddlywiki/data/usr/share/freedombox/modules-enabled/tiddlywiki create mode 100644 plinth/modules/tiddlywiki/forms.py create mode 100644 plinth/modules/tiddlywiki/manifest.py create mode 100644 plinth/modules/tiddlywiki/privileged.py create mode 100644 plinth/modules/tiddlywiki/static/icons/tiddlywiki.png create mode 100644 plinth/modules/tiddlywiki/static/icons/tiddlywiki.svg create mode 100644 plinth/modules/tiddlywiki/templates/tiddlywiki_configure.html create mode 100644 plinth/modules/tiddlywiki/templates/tiddlywiki_delete.html create mode 100644 plinth/modules/tiddlywiki/templates/tiddlywiki_upload_file.html create mode 100644 plinth/modules/tiddlywiki/tests/__init__.py create mode 100644 plinth/modules/tiddlywiki/tests/data/dummy_wiki.html create mode 100644 plinth/modules/tiddlywiki/tests/test_functional.py create mode 100644 plinth/modules/tiddlywiki/urls.py create mode 100644 plinth/modules/tiddlywiki/views.py diff --git a/debian/copyright b/debian/copyright index 64b25d866..577de3278 100644 --- a/debian/copyright +++ b/debian/copyright @@ -284,6 +284,13 @@ Copyright: Jakob Borg and the Syncthing project Comment: https://commons.wikimedia.org/wiki/File:SyncthingLogoHorizontal.svg License: MPL-2.0 +Files: plinth/modules/tiddlywiki/static/icons/tiddlywiki.svg + plinth/modules/tiddlywiki/static/icons/tiddlywiki.png +Copyright: 2004-2007 Jeremy Ruston + 2007-2016 UnaMesa Association +Comment: https://github.com/Jermolene/TiddlyWiki5/blob/086506012d98e9db34c7d96dc27aea249a9bdbc8/editions/introduction/tiddlers/images/Motovun%20Jack.svg +License: BSD-3-clause + Files: plinth/modules/tor/static/icons/tor.png plinth/modules/tor/static/icons/tor.svg Copyright: The Tor Project, Inc. diff --git a/plinth/modules/tiddlywiki/__init__.py b/plinth/modules/tiddlywiki/__init__.py new file mode 100644 index 000000000..dd7d673ed --- /dev/null +++ b/plinth/modules/tiddlywiki/__init__.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +FreedomBox app for TiddlyWiki. + +This is a FreedomBox-native implementation of a TiddlyWiki Nest. +This app doesn't install any Debian packages. +""" + +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module +from plinth import cfg, frontpage, menu +from plinth.config import DropinConfigs +from plinth.modules.apache.components import Webserver +from plinth.modules.backups.components import BackupRestore +from plinth.modules.firewall.components import Firewall +from plinth.modules.users.components import UsersAndGroups +from plinth.utils import format_lazy + +from . import manifest, privileged + +_description = [ + format_lazy( + _('TiddlyWiki is an interactive application that runs entirely in the ' + 'web browser. Each wiki is a self-contained HTML file stored on your' + ' {box_name}. Instead of writing long wiki pages, TiddlyWiki ' + 'encourages you to write several short notes called Tiddlers and ' + 'link them together into a dense graph.'), box_name=cfg.box_name), + _('It is a versatile application with a wide variety of use cases - ' + 'non-linear notebook, website, personal knowledge base, task and project' + ' management system, personal diary etc. Plugins can extend the ' + 'functionality of TiddlyWiki. Encrypting individual tiddlers or ' + 'password-protecting a wiki file is possible from within the ' + 'application.'), + format_lazy( + _('TiddlyWiki is downloaded from {box_name} website and not from ' + 'Debian. Wikis need to be upgraded to newer version manually.'), + box_name=_(cfg.box_name)), + format_lazy( + _('Wikis are not public by default, but they can be downloaded for ' + 'sharing or publishing. They can be edited by ' + 'any user on {box_name} belonging to the wiki group. ' + 'Simultaneous editing is not supported.'), box_name=cfg.box_name, + users_url=reverse_lazy('users:index')), + _('Create a new wiki or upload your existing wiki file to get started.') +] + + +class TiddlyWikiApp(app_module.App): + """FreedomBox app for TiddlyWiki.""" + + app_id = 'tiddlywiki' + + _version = 1 + + def __init__(self): + """Create components for the app.""" + super().__init__() + + groups = {'wiki': _('View and edit wiki applications')} + + info = app_module.Info(self.app_id, self._version, + name=_('TiddlyWiki'), + icon_filename='tiddlywiki', + short_description=_('Non-linear Notebooks'), + description=_description, + manual_page='TiddlyWiki', + clients=manifest.clients) + self.add(info) + + menu_item = menu.Menu('menu-tiddlywiki', info.name, + info.short_description, info.icon_filename, + 'tiddlywiki:index', parent_url_name='apps') + self.add(menu_item) + + # The shortcut is a simple directory listing provided by Apache server. + # Expecting a large number of wiki files, so creating a shortcut for + # each file (like in ikiwiki's case) will crowd the front page. + shortcut = frontpage.Shortcut( + 'shortcut-tiddlywiki', info.name, + short_description=info.short_description, icon=info.icon_filename, + description=info.description, manual_page=info.manual_page, + url='/tiddlywiki/', clients=info.clients, login_required=True, + allowed_groups=list(groups)) + self.add(shortcut) + + dropin_configs = DropinConfigs('dropin-configs-tiddlywiki', [ + '/etc/apache2/conf-available/tiddlywiki-freedombox.conf', + ]) + self.add(dropin_configs) + + firewall = Firewall('firewall-tiddlywiki', info.name, + ports=['http', 'https'], is_external=True) + self.add(firewall) + + webserver = Webserver('webserver-tiddlywiki', 'tiddlywiki-freedombox') + self.add(webserver) + + users_and_groups = UsersAndGroups('users-and-groups-tiddlywiki', + groups=groups) + self.add(users_and_groups) + + backup_restore = BackupRestore('backup-restore-tiddlywiki', + **manifest.backup) + self.add(backup_restore) + + def setup(self, old_version=None): + """Install and configure the app.""" + super().setup(old_version) + privileged.setup() + if not old_version: + self.enable() + + def uninstall(self): + """Purge directory with all the wikis.""" + super().uninstall() + privileged.uninstall() + + +def get_wiki_list(): + """List all the TiddlyWiki files.""" + return sorted([ + path.name for path in privileged.wiki_dir.iterdir() + if path.suffix == '.html' + ]) diff --git a/plinth/modules/tiddlywiki/data/usr/share/freedombox/etc/apache2/conf-available/tiddlywiki-freedombox.conf b/plinth/modules/tiddlywiki/data/usr/share/freedombox/etc/apache2/conf-available/tiddlywiki-freedombox.conf new file mode 100644 index 000000000..f968db2c1 --- /dev/null +++ b/plinth/modules/tiddlywiki/data/usr/share/freedombox/etc/apache2/conf-available/tiddlywiki-freedombox.conf @@ -0,0 +1,26 @@ +## +## On all sites, provide TiddlyWiki files on a path: /tiddlywiki +## + +Alias /tiddlywiki /var/lib/tiddlywiki + + + SetEnvIf Request_Method HEAD no-gzip + Include includes/freedombox-single-sign-on.conf + + TKTAuthToken "admin" "wiki" + + + + + Dav On + + # Don't accept overrides in .htaccess + AllowOverride None + + # Disable following symlinks, show an index page + Options Indexes + + # Accept and serve only HTML files + ForceType text/html + diff --git a/plinth/modules/tiddlywiki/data/usr/share/freedombox/modules-enabled/tiddlywiki b/plinth/modules/tiddlywiki/data/usr/share/freedombox/modules-enabled/tiddlywiki new file mode 100644 index 000000000..be7632a2b --- /dev/null +++ b/plinth/modules/tiddlywiki/data/usr/share/freedombox/modules-enabled/tiddlywiki @@ -0,0 +1 @@ +plinth.modules.tiddlywiki diff --git a/plinth/modules/tiddlywiki/forms.py b/plinth/modules/tiddlywiki/forms.py new file mode 100644 index 000000000..227aa1601 --- /dev/null +++ b/plinth/modules/tiddlywiki/forms.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django forms for configuring TiddlyWiki.""" + +from django import forms +from django.core import validators +from django.utils.translation import gettext_lazy as _ + + +class CreateWikiForm(forms.Form): + """Form to create a new wiki file.""" + + name = forms.CharField( + label=_('Name of the wiki file, with file extension ".html"'), + strip=True, help_text=_( + 'Wiki title and description can be set from within the wiki. ' + 'This file name is independent of the wiki title.')) + + +class RenameWikiForm(forms.Form): + """Form to rename a wiki file.""" + + new_name = forms.CharField( + label=_('New name for the wiki file, with file extension ".html"'), + strip=True, help_text=_( + 'Renaming the file has no effect on the title of the wiki.')) + + +class UploadWikiForm(forms.Form): + """Form to upload a wiki file.""" + + file = forms.FileField( + label=_('A TiddlyWiki file with .html file extension'), + required=True, validators=[ + validators.FileExtensionValidator( + ['html'], _('TiddlyWiki files must be in HTML format')) + ], help_text=_( + 'Upload an existing TiddlyWiki file from this computer.')) diff --git a/plinth/modules/tiddlywiki/manifest.py b/plinth/modules/tiddlywiki/manifest.py new file mode 100644 index 000000000..9b9f4d29f --- /dev/null +++ b/plinth/modules/tiddlywiki/manifest.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Application manifest for TiddlyWiki.""" + +from django.utils.translation import gettext_lazy as _ + +from .privileged import wiki_dir + +clients = [{ + 'name': _('TiddlyWiki'), + 'platforms': [{ + 'type': 'web', + 'url': '/tiddlywiki/' + }] +}] + +backup = {'data': {'directories': [str(wiki_dir)]}} diff --git a/plinth/modules/tiddlywiki/privileged.py b/plinth/modules/tiddlywiki/privileged.py new file mode 100644 index 000000000..3f982794d --- /dev/null +++ b/plinth/modules/tiddlywiki/privileged.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Configure TiddlyWiki.""" + +import pathlib +import re +import shutil +import tempfile +import urllib.request + +from plinth.actions import privileged + +EMPTY_WIKI_FILE = 'https://ftp.freedombox.org/pub/tiddlywiki/empty.html' + +wiki_dir = pathlib.Path('/var/lib/tiddlywiki') + + +def _set_ownership(path: pathlib.Path): + """Makes www-data:www-data the owner of the give path.""" + shutil.chown(path, user='www-data', group='www-data') + + +@privileged +def setup(): + """Setup wiki dir and CGI script.""" + wiki_dir.mkdir(parents=True, exist_ok=True) + _set_ownership(wiki_dir) + + +def _normalize_wiki_file_name(name): + """Return a normalized file name from a wiki name.""" + file_name = name.replace(' ', '_') + invalid_characters = r'[\/\\\:\*\?\"\'\<\>\|]' + file_name = re.sub(invalid_characters, '', file_name) + if not file_name.endswith('.html'): + return file_name + '.html' + + return file_name + + +@privileged +def create_wiki(file_name: str): + """Initialize wiki with the latest version of TiddlyWiki.""" + file_name = _normalize_wiki_file_name(file_name) + response = urllib.request.urlopen(EMPTY_WIKI_FILE) + file_path = wiki_dir / file_name + if file_path.exists(): + raise ValueError('Wiki exists') + + file_path.write_bytes(response.read()) + _set_ownership(file_path) + + +@privileged +def add_wiki_file(upload_file: str): + """Add an uploaded wiki file.""" + upload_file_path = pathlib.Path(upload_file) + temp_dir = tempfile.gettempdir() + if not upload_file_path.is_relative_to(temp_dir): + raise Exception('Uploaded file is not in expected temp directory.') + + file_name = _normalize_wiki_file_name(upload_file_path.name) + file_path = wiki_dir / file_name + if file_path.exists(): + raise ValueError('Wiki exists') + + shutil.move(upload_file_path, file_path) + _set_ownership(file_path) + + +@privileged +def rename_wiki(old_name: str, new_name: str): + """Rename wiki file.""" + old_name = _normalize_wiki_file_name(old_name) + new_name = _normalize_wiki_file_name(new_name) + file_path = wiki_dir / new_name + if file_path.exists(): + raise ValueError('Wiki exists') + + (wiki_dir / old_name).rename(file_path) + + +@privileged +def delete_wiki(file_name: str): + """Delete one wiki file by name.""" + file_name = _normalize_wiki_file_name(file_name) + (wiki_dir / file_name).unlink(missing_ok=True) + + +@privileged +def uninstall(): + """Delete all the wiki content.""" + shutil.rmtree(wiki_dir) diff --git a/plinth/modules/tiddlywiki/static/icons/tiddlywiki.png b/plinth/modules/tiddlywiki/static/icons/tiddlywiki.png new file mode 100644 index 0000000000000000000000000000000000000000..f85ccb6cf4dfae28c000bf666b6e3c86dcdf5ee6 GIT binary patch literal 6109 zcmdT|_g9lm)4oHHUX@-1DI&d!2vVeXkdCwf5+4KtQW82rBT^I|R7yaaQbf9;CIkWs zDug0ZC7~%w4Il_eC|}<9`zzib_RQ|g&dfQpvwK}<)9h`{*ja>F003aOFgJDp01#~o z0+<+R!ZZ90lqQ(N%-tgZfYJ9q1C;Z=y_KdEj5KkJbPR{{eDt@^RCf1zhO6Wy-@Nh5YxE-?WrOh5|E6TH(^-wWOE# z#uBLTgUzleO0~zYlN)xrtC3T>zX;D`ac_sAl+4s0)iP0FV=2*dNb`OkKtj+FNWEhX z5`U)pzpH4f1VNxR`7_*5cs{z0v}&4Geg~?9o~eMsAEHli=sGmkrYRrCd0aVzCYYsn zC-#%O$zNR3r0kF1QI!`|j?Tj4=?-7vO3>xL-0baO6%5(^{tkF!y5nbRxi7Q^;0HZc z-^RLu54+F_-L{AiP^f=F{8@QQcf+@|{#2=bMM#P7@ZT$qxoG?dIWMZz=1cyJPO?m# zP*;a~qtW{gyU1E+31%Mt_f@xVoRA6bP$+jH&r|d!3_1n)lTMEMJ&Gg?;(yk2G+Rlf zElr-i;7;5SU--l{lvWwt6vMkMyx@fB7CE}Ho<9?diVWJz8`iibmF7;Ue0@lo8Va6z zC+w)HKKqTB2s&ntW(|9R^C3}pyAS{d3EiUq*&p)C+~M_!jUK~c8oJH{>ObHI{W`QE zu>gf^EzRZuC$W3ps|*^L4uY`IK+H6GDvRWjEkz8rTFBJQ=wO&8lk@UM%UjViP=YJ+ zWc7?Sk-M=1r$2dcT&yQ{uk-L`_esUvzGB%EGSc7jN|hqCwFzBh#ncQIn9hlb{N86* z@_b*YoCN=Rt9)R92|y|r&Jdps2yv^=ejz5ZkZhu_cQ`!%N}(SP-2Xdw_Y}e5a0(7- zvoC)@3>H#(UB2W(r=)_pfD9n0_Z{$q4?qWb*I@)=KX>o?cfzn@{EzaUi?{lDBnuhj zjX~m3e||n2EM$AB`e`J$V1~{x?`Vk7LSUzKl~42z1xxov#fmTfJ)KZY+~Z!TdUz7X zwjB%E5Z6y?pI}IjM0;twP010UeP49Gg`v?y;6%=2<**{0@+S3?x`$Z4MRdj)9HR03 zSt7{|=Hrjte0&%-$Sge;F!!SURFwel+lg5~`;`tC5nKqZ1a0Z@-Q7i@ramtHq%rO@GpEGEf0p~)MUTVV^RjdakG#^#oDlgO!-g7bAP0=Z1%-8xbGNsg@D3OzG1Sl|`YLhH< z3HI_YJL{W&raBokbaM^e;lSTPPe7uRhW5DnSvdOz;wwwduYHq?0&Cy7Me|fS zE=Xc*BlBdlT$wx@T*gm+t$V4#(4LSV0awKcQW8C=AZycGw?2Kl^CNgcSDB+HzaK-!F7fhE9U5C5f#h>|sO% zgk@;TJ9vT>s#&5E_=5wY7gmUy|LUEA**+R)Unx!YNW7}b(4MwE3q9ugg`XwDBM+_z zG}mUPM=iC{!Id-79|SG&LYM51*zaD&m=InrwO?%iO!mOC6U}Wwqt8?+rw*#@_urt+ z8D9EBXWOav

*Vr6Vt|ZqG=kz|(fdHQ3&q8Fy%8{t74gk#*Y30~u;XSR{Gps4O!F4Zyd~Tw zNI}SYJ_lX&MF!JZ;j;3@{v}du!R}5XS)e50&!SJ!HkTWuDYo!~5q89w0DwpuCJxi- z%^LPp70h5LiHSJHjmyw|y#TjVO96Z{96ExHyu| z8Y9~KNVi&Ra((xYcJsbfr8KlvpuJ5Cb`A#msp)ZA&X zL@1Pb!Khs<3T-3qdB|7^vociX!b(u#`nI&SbLWLvuJ+`xyAfB*YN#1(q`%lBZ{FbXA0lwLK^ie?{2!j&#N=9Pg=d^O=)cS@(%N1&B%i!HpEyX2!KEzgQ`Y5 zxVW;vc+hoXb8xG<*bdT(21I}1ucq*2iJhUqKew>Ui7-UBz%*E3ztzzA8Mbobf| zANkC9Me=ON+(Sx2^lLcKyvI?x$~dIQ6wetw@rH#fTfm$Caw|4$ z(JU#!;?{_trDu*yxnb-5gAU0Pduckz6=20N#U~RMh58w%{O>E#oN;I z#B-Sv_vGip98RPfdM3PixMY09R&O9dCjuw%+d}7aU*Bu|;iYaJfh zcT1u2;c?9EvcAmW*{~?y#wwh${OiVAI$D2Gq&N7*$3iHKb1RgrJZA_j3jn$;$Uye3 zX!_|IA}s?O661Ky;122(M2tMp8Bo%~0bvIo$aTjyqj&fDMu&Hgya@G_i_2tZILKb> zY7|(CbrAVJxeb){bZM7%S^mD>?8^=UJgxK96wZH*r#(L8B)e(5@2-l86pjT^%4xF8 zU2LXHE@*+G40>D%dpth3726$lU`9?*hPW;`^!{v1AalNC-LMdtKU=reP{F|nUBnH; z1?2eVzu$zy3+mBzHUblEAIK%^o|vQNOS^y@fBQK%9Btch)W?aQ2koh09x{#iNCZTuG~WEUG|nz)JBa}Bq?5;l`_;3TTJFQIc^;SQ zAQOoj_PqiiD>C2eZRPXDCt{Z5e_d8MN;wf{9NQM(ggc~TbTA%DiKC(hj?h+CU!KX7 z>RAb|UT;_U8PO0y*buxY185hhXG4<+i3!moM#C+sEkOgGS00ec+z$cK&^X*A{I zg9QtGlc7}`+baTbY5VW4o9LM}f)Sy{vCTv-W5Uk)=c*<`i0BQsT(S0&qZP_rDam9xb%j+P#7 z>T~92V-+2)Pk|V5RD89t1D`G(TeC9uu%i1HQj&=^V)Ew_BN?>k50wWefm+SUeAQ3n zlkM86560QkZX$pa(FaP2%*FJ2w$qQQa{2XFI2L%Jtqft;8lh{g=HdF1oh$ND7c%7> zlnE>{r5XANR-_#^P7`Og%0YVu>ym}5;o{o9uCJdW0|`9Mu$%58hf3RzoH8ORpCtf1 zcbAC~z# zIwF!Twzp^<$;PTrGHL>q=O?dDmugi4LA+*F6!Bt-PM?m9u)-7Rx7_yp=ZpW~{QlMp zyz8Qo`l<79rnMB+v$%Gua{wXFpoXbqdS9>O z%dA2sZ*AEpcuC@P0QK4Dl#WACqDNbX7=avm?vzxv23=nYRjQ$#!VW>OdREm?-;Rt- z9bPpM<#;)l9E){u0Riz(NBQRzy)V~ldbskR$6oNq))i{5tuzPI5s2_gs>|OZ#|?<5 zRo@|UP{MVW`e_-_qCi(FAb2nzIiIw}TP-{a;x|<~%l(f5h7&hkSC9N9r(f~(xTaB_ zIbzGddQ;R%@SYd|Z3Tr{ufd=6IueGeg}DoH-NvK?kt|aM-1>wbfDFCRU;mLSf~)T; zrFc-OkKaUr`Y1I3fxpd&L=Vx=U8p|HWB!`z|JVP+yPBVt3uyCz29avg?KdF+&+f5Z zrklye&c;lzV2b0{fl68q z21IvmG^nw0b|0qYp~F!!$Ah(n9qBiRcrpXM+fMzxw0@s^fYFVonk1LOTj{}`WZ893 z7enJdw{WBPLjjz;NO{_J+_axw;tTE2Ww6e4Yo&v5wQwSrUMYU>^TOl= z{mik4seeaKnaSba`f-rHeIQnvDs%_Y4u=I3nB+43vk#`7;APV(n7sAW3Fh3#1|Sn8 z^5%2iG0})7a-TotCB1d%l>=z6pQI{L&PDAlUc$}4yV@1d$Tk6Zx-6+=xLD9J4cr(q zgE1tiCOyX!dl)Z1H#$gjuOJYrDia!2L2EiP|9L*fz>j7b>?Xp4#q9BAL+=W&cTM-b zq4>?88oBX{FQ=5}ys)7~WNyY>9lyu+`^q9^9?+XIof)d-d^z=lY($Qf01UD~;5)p4 zNfyB(gb$O27S%APwSf-|b8Nar&mr4Ee7ibsj@KQsW<~8~JJX_BQE80g8b-A6gg~~V z&kUh$oY%nASt(&GH7WMG7m#=zCs)@`Nb6pPL2LOs5=b=ksUoH4}j98#_eVOvno>Cn7Jh`OMz1BTKHe|X7 zyI*_%-%Ph$8m4j_dK3m&Gb#x!b^NwpkO`UmG9{c`GP!!aRbYMeGo?fI$5*^|Z7`TX zE&=NqQX{qwfMG?KOR(DS0lh9ix&twpUHc45su*W01@(#+r0qfYifZY zX|F(WO>+tOgwJFFRUrM9O5JnB;J99vVcFvy&A+fL@uCdK)c$PSm z%T3WdHJJ9p3nzb%X%~2wE&G0h*0ojv-+Pks1}waQ<6@m_s9k8 zjV*~i^W$l9$)*ARy0$q5%~niyl!U+8ZBKJ#tB5bez;d3dC*ok^B9R)iN7{2 zvA~2lyY+ZIWPn&KJ*Q;#K87pbCD^_|^beiJl_SbLr{{&H0$CHUUtD(;lNW25Uxq@> zq~74r-7>XtYTG-gKQa}-Kd+)M$1^U6DvYh?)GH(O&mG2>$ENHms?T1#MNa6O?g^C2 zyclebAj<*_i=u!NnEf0LxR%viQG*eY~VIU2{-LtU5#IOPk@o*cfjpbk%h)j z6V?*7pWWoK;Fa(`%x@*aK0yWkTvR*iO2ds!9 z$1`#B=FuYGG8&1q|M2U(vm67_{-Vk>(u%xOIvsx}4ieE@`c@W&kh= GJorC?Onhem literal 0 HcmV?d00001 diff --git a/plinth/modules/tiddlywiki/static/icons/tiddlywiki.svg b/plinth/modules/tiddlywiki/static/icons/tiddlywiki.svg new file mode 100644 index 000000000..1b4f1cd36 --- /dev/null +++ b/plinth/modules/tiddlywiki/static/icons/tiddlywiki.svg @@ -0,0 +1,65 @@ + + + + + 2012-05-10 07:32Z + + + + + Canvas 1 + + Layer 1 + + + + diff --git a/plinth/modules/tiddlywiki/templates/tiddlywiki_configure.html b/plinth/modules/tiddlywiki/templates/tiddlywiki_configure.html new file mode 100644 index 000000000..75cb40b9d --- /dev/null +++ b/plinth/modules/tiddlywiki/templates/tiddlywiki_configure.html @@ -0,0 +1,60 @@ +{% extends "app.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + {{ block.super }} + +

{% trans "Manage Wikis" %}

+ + + +
+
+ {% if not wikis %} +

{% trans 'No wikis available.' %}

+ {% else %} +
+ {% for wiki in wikis %} + + {% endfor %} +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/plinth/modules/tiddlywiki/templates/tiddlywiki_delete.html b/plinth/modules/tiddlywiki/templates/tiddlywiki_delete.html new file mode 100644 index 000000000..ec2bd20bb --- /dev/null +++ b/plinth/modules/tiddlywiki/templates/tiddlywiki_delete.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

+ {% blocktrans trimmed %} + Delete wiki {{ name }} + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Hint: You can download a copy of this wiki from within + TiddlyWiki before deleting it. + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Delete this wiki file permanently?

+ {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/tiddlywiki/templates/tiddlywiki_upload_file.html b/plinth/modules/tiddlywiki/templates/tiddlywiki_upload_file.html new file mode 100644 index 000000000..f26c32703 --- /dev/null +++ b/plinth/modules/tiddlywiki/templates/tiddlywiki_upload_file.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ +{% endblock %} diff --git a/plinth/modules/tiddlywiki/tests/__init__.py b/plinth/modules/tiddlywiki/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/tiddlywiki/tests/data/dummy_wiki.html b/plinth/modules/tiddlywiki/tests/data/dummy_wiki.html new file mode 100644 index 000000000..99ed9e761 --- /dev/null +++ b/plinth/modules/tiddlywiki/tests/data/dummy_wiki.html @@ -0,0 +1,9 @@ + + + + Dummy TiddlyWiki File + + +

This is a not a real TiddlyWiki file.

+ + diff --git a/plinth/modules/tiddlywiki/tests/test_functional.py b/plinth/modules/tiddlywiki/tests/test_functional.py new file mode 100644 index 000000000..aa603a6a1 --- /dev/null +++ b/plinth/modules/tiddlywiki/tests/test_functional.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Functional, browser based tests for TiddlyWiki app.""" + +import pathlib + +import pytest + +from plinth.tests import functional + +pytestmark = [pytest.mark.apps, pytest.mark.tiddlywiki] + +wiki_name = 'Engineering Daybook' +file_name = 'Engineering_Daybook.html' + + +class TestTiddlyWikiApp(functional.BaseAppTests): + app_name = 'tiddlywiki' + has_service = False + has_web = True + + def _create_wiki_file(self, session_browser): + """Add a wiki to using the 'Create' functionality.""" + functional.nav_to_module(session_browser, 'tiddlywiki') + + wiki_link = f'/tiddlywiki/{file_name}' + if self._get_links_in_app_page(session_browser, wiki_link): + return + + session_browser.links.find_by_href( + '/plinth/apps/tiddlywiki/create/').first.click() + session_browser.find_by_id('id_tiddlywiki-name').fill(wiki_name) + functional.submit(session_browser, form_class='form-tiddlywiki') + + def _get_links_in_app_page(self, session_browser, link): + """Return the links matching a href in the app page.""" + functional.nav_to_module(session_browser, 'tiddlywiki') + return session_browser.links.find_by_href(link) + + def _get_links_in_apache_listing(self, session_browser, link): + """Return the links matching a href in the index page.""" + default_url = functional.config['DEFAULT']['url'] + session_browser.visit(f'{default_url}/tiddlywiki') + return session_browser.links.find_by_href(link) + + def _assert_wiki_present(self, session_browser, file_name, present=True): + """Assert that a wiki is present.""" + wiki_link = f'/tiddlywiki/{file_name}' + assert bool(self._get_links_in_app_page(session_browser, + wiki_link)) == present + assert bool( + self._get_links_in_apache_listing(session_browser, + file_name)) == present + + def _assert_wiki_works(self, session_browser, file_name): + """Assert that wiki loads and run as expected.""" + wiki_link = f'/tiddlywiki/{file_name}' + default_url = functional.config['DEFAULT']['url'] + session_browser.visit(f'{default_url}{wiki_link}') + links = session_browser.links.find_by_href( + 'https://tiddlywiki.com/#GettingStarted') + assert len(links) == 1 + + def test_wiki_file_access(self, session_browser): + """Test creating a new wiki file.""" + self._create_wiki_file(session_browser) + + self._assert_wiki_present(session_browser, file_name) + self._assert_wiki_works(session_browser, file_name) + + def test_rename_wiki_file(self, session_browser): + """Test changing the name of a wiki file.""" + self._create_wiki_file(session_browser) + + new_wiki_name = 'A Midsummer Night\'s Dream' + new_file_name = 'A_Midsummer_Nights_Dream.html' + self._get_links_in_app_page( + session_browser, + '/plinth/apps/tiddlywiki/' + file_name + '/rename/').first.click() + session_browser.find_by_id('id_tiddlywiki-new_name').fill( + new_wiki_name) + functional.submit(session_browser, form_class='form-tiddlywiki') + + self._assert_wiki_present(session_browser, new_file_name) + self._assert_wiki_works(session_browser, new_file_name) + + def test_upload_wiki_file(self, session_browser): + """Test uploading an existing wiki file.""" + _test_data_dir = pathlib.Path(__file__).parent / 'data' + test_wiki_file = str(_test_data_dir / 'dummy_wiki.html') + + session_browser.links.find_by_href( + '/plinth/apps/tiddlywiki/upload/').first.click() + session_browser.attach_file('tiddlywiki-file', test_wiki_file) + functional.submit(session_browser, form_class='form-tiddlywiki') + + self._assert_wiki_present(session_browser, 'dummy_wiki.html') + + def test_delete_wiki_file(self, session_browser): + """Test deleting an existing wiki file""" + self._create_wiki_file(session_browser) + + self._get_links_in_app_page( + session_browser, + '/plinth/apps/tiddlywiki/' + file_name + '/delete/').first.click() + functional.submit(session_browser, form_class='form-delete') + + self._assert_wiki_present(session_browser, file_name, present=False) diff --git a/plinth/modules/tiddlywiki/urls.py b/plinth/modules/tiddlywiki/urls.py new file mode 100644 index 000000000..2a395f2b8 --- /dev/null +++ b/plinth/modules/tiddlywiki/urls.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the TiddlyWiki app.""" + +from django.urls import re_path + +from .views import (CreateWikiView, TiddlyWikiAppView, RenameWikiView, + UploadWikiView, delete) + +urlpatterns = [ + re_path(r'^apps/tiddlywiki/$', TiddlyWikiAppView.as_view(), + name='index'), + re_path(r'^apps/tiddlywiki/create/$', CreateWikiView.as_view(), + name='create'), + re_path(r'^apps/tiddlywiki/upload/$', UploadWikiView.as_view(), + name='upload'), + re_path(r'^apps/tiddlywiki/(?P.+\.html)/rename/$', + RenameWikiView.as_view(), name='rename'), + re_path(r'^apps/tiddlywiki/(?P.+\.html)/delete/$', delete, + name='delete'), +] diff --git a/plinth/modules/tiddlywiki/views.py b/plinth/modules/tiddlywiki/views.py new file mode 100644 index 000000000..324a827c5 --- /dev/null +++ b/plinth/modules/tiddlywiki/views.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Django views for TiddlyWiki.""" + +import tempfile + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView + +from plinth import app as app_module +from plinth import views +from plinth.modules import tiddlywiki + +from . import privileged +from .forms import CreateWikiForm, RenameWikiForm, UploadWikiForm + +DUPLICATE_FILE_ERROR = _('A wiki file with the given name already exists.') + + +class TiddlyWikiAppView(views.AppView): + """Serve configuration page.""" + + app_id = 'tiddlywiki' + template_name = 'tiddlywiki_configure.html' + + def get_context_data(self, *args, **kwargs): + """Add wikis to the context data.""" + context = super().get_context_data(*args, **kwargs) + context['wikis'] = tiddlywiki.get_wiki_list() + return context + + +class CreateWikiView(SuccessMessageMixin, FormView): + """View to create a new repository.""" + + form_class = CreateWikiForm + prefix = 'tiddlywiki' + template_name = 'form.html' + success_url = reverse_lazy('tiddlywiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Create Wiki') + return context + + def form_valid(self, form): + """Create the repository on valid form submission.""" + try: + privileged.create_wiki(form.cleaned_data['name']) + self.success_message = _('Wiki created.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while creating the wiki.'), error)) + + return super().form_valid(form) + + +class RenameWikiView(SuccessMessageMixin, FormView): + """View to edit an existing repository.""" + + form_class = RenameWikiForm + prefix = 'tiddlywiki' + template_name = 'form.html' + success_url = reverse_lazy('tiddlywiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Rename Wiki') + return context + + def form_valid(self, form): + """Rename the wiki on valid form submission.""" + try: + privileged.rename_wiki(self.kwargs['old_name'], + form.cleaned_data['new_name']) + self.success_message = _('Wiki renamed.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format( + _('An error occurred while renaming the wiki.'), error)) + + return super().form_valid(form) + + +class UploadWikiView(SuccessMessageMixin, FormView): + """View to upload an existing wiki file.""" + + form_class = UploadWikiForm + prefix = 'tiddlywiki' + template_name = 'tiddlywiki_upload_file.html' + success_url = reverse_lazy('tiddlywiki:index') + + def get_context_data(self, **kwargs): + """Return additional context for rendering the template.""" + context = super().get_context_data(**kwargs) + context['title'] = _('Upload Wiki File') + return context + + def form_valid(self, form): + """Add the wiki file on valid form submission.""" + multipart_file = self.request.FILES['tiddlywiki-file'] + + try: + with tempfile.TemporaryDirectory() as temp_dir: + wiki_file_name = temp_dir + '/' + multipart_file.name + with open(wiki_file_name, 'wb+') as wiki_file: + for chunk in multipart_file.chunks(): + wiki_file.write(chunk) + + privileged.add_wiki_file(wiki_file_name) + + self.success_message = _('Wiki file added.') + except ValueError: + messages.error(self.request, DUPLICATE_FILE_ERROR) + except Exception as error: + messages.error( + self.request, "{0} {1}".format(_('Failed to add wiki file.'), + error)) + return redirect(reverse_lazy('tiddlywiki:index')) + + return super().form_valid(form) + + +def delete(request, name): + """Handle deleting wikis, showing a confirmation dialog first. + + On GET, display a confirmation page. + On POST, delete the wiki. + """ + app = app_module.App.get('tiddlywiki') + if request.method == 'POST': + try: + privileged.delete_wiki(name) + messages.success(request, _('{name} deleted.').format(name=name)) + except Exception as error: + messages.error( + request, + _('Could not delete {name}: {error}').format( + name=name, error=error)) + + return redirect(reverse_lazy('tiddlywiki:index')) + + return TemplateResponse(request, 'tiddlywiki_delete.html', { + 'title': app.info.name, + 'name': name + })