2025.09.17-19:07:57

This commit is contained in:
2025-09-17 19:07:58 +02:00
parent ff37c9cd8b
commit 38a85cb9d5
47 changed files with 1530 additions and 38 deletions

View File

@@ -21,6 +21,6 @@ COPY . .
RUN poetry install --all-groups \ RUN poetry install --all-groups \
&& chmod 0755 start-django.sh && chmod 0755 start-django.sh
HEALTHCHECK --interval=30s --retries=5 --timeout=30s CMD curl -sS 127.0.0.1:8000
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/start-django.sh"] ENTRYPOINT ["/app/start-django.sh"]

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
Copyright 2025 Christian Moser
Redistribution and use in source and binary forms, with or without
modification,are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -11,13 +11,20 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
from pathlib import Path from pathlib import Path
from django.urls import reverse_lazy
import sys import sys
from environ import Env from environ import Env
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
LOCAL_DIR = Path(__file__).resolve().parent / "local"
ENV = Env( ENV = Env(
DEBUG=(bool,False), DEBUG=(bool,False),
DOTENV=(Path,LOCAL_DIR/'.env'),
DOTENV_PROD=(Path,LOCAL_DIR/'.env.prod'),
DOTENV_DEVEL=(Path,LOCAL_DIR/'.env.dev'),
DATABASE_URL=(str,f"sqlite:///{str(BASE_DIR/"db.sqlite3").replace("\\","/")}"), DATABASE_URL=(str,f"sqlite:///{str(BASE_DIR/"db.sqlite3").replace("\\","/")}"),
ALLOWED_HOSTS=(list,['*']), ALLOWED_HOSTS=(list,['*']),
STATIC_URL=(str,"static/"), STATIC_URL=(str,"static/"),
@@ -28,15 +35,18 @@ ENV = Env(
EMAIL_BACKEND=(str,"console"), EMAIL_BACKEND=(str,"console"),
) )
DEBUG = ENV.bool("DEBUG") _env_file = Path(ENV.path("DOTENV"))
if _env_file.is_file():
ENV.read_env(_env_file)
DEBUG = ENV.bool("DEBUG") DEBUG = ENV.bool("DEBUG")
if DEBUG: if DEBUG:
_env_file = Path(ENV.path("DOTENV",str(BASE_DIR/'.env.dev'))).resolve() _env_file = Path(ENV.path("DOTENV_DEVEL")).resolve()
if _env_file.is_file(): if _env_file.is_file():
ENV.read_env(_env_file) ENV.read_env(_env_file)
else: else:
_env_file = Path(ENV.path("DOTENV",str(BASE_DIR/'.env.prod'))).resolve() _env_file = Path(ENV.path("DOTENV_PROD")).resolve()
if _env_file.is_file(): if _env_file.is_file():
ENV.read_env(_env_file) ENV.read_env(_env_file)
del _env_file del _env_file
@@ -119,7 +129,7 @@ ROOT_URLCONF = 'django_project.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR/"templates"],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -174,6 +184,8 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
# Auth settings
LOGIN_REDIRECT_URL = reverse_lazy('tinywiki:home')
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
@@ -186,6 +198,9 @@ MEDIA_ROOT = ENV("MEDIA_ROOT")
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATICFILES_DIRS = [
BASE_DIR/"static",
]
if ENV("EMAIL_BACKEND") == 'smtp': if ENV("EMAIL_BACKEND") == 'smtp':
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
@@ -201,4 +216,24 @@ else:
print("Email backend not known falling back to console!",file=sys.stderr) print("Email backend not known falling back to console!",file=sys.stderr)
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
_secret_key = LOCAL_DIR / "secret_key.py"
if _secret_key.is_file():
from .local import secret_key
if hasattr(secret_key,'SECRET_KEY'):
SECRET_KEY = secret_key.SECRET_KEY
if hasattr(secret_key,"SECRET_KEY_FALLBACKS"):
SECRET_KEY_FALLBACKS = secret_key.SECRET_KEY_FALLBACKS
_local_settings = LOCAL_DIR / "settings.py"
if _local_settings.is_file():
from .local.settings import *
if DEBUG:
_local_settings = LOCAL_DIR / "settings_dev.py"
if _local_settings.is_file():
from .local.settings_dev import *
else:
_local_settings = LOCAL_DIR / "settings_prod.py"
if _local_settings.is_file():
from .local.settings_prod import *
del _local_settings

View File

@@ -21,6 +21,7 @@ urlpatterns = [
path('',include("tinywiki.urls")), path('',include("tinywiki.urls")),
path("user/",include("user.urls")), path("user/",include("user.urls")),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/',include('allauth.urls')),
] ]
if settings.DEBUG: if settings.DEBUG:

59
package-lock.json generated Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "tinywiki",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
}
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

90
templates/base.html Normal file
View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
{% load i18n static %}
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TinyWiki</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
{% block extra_css %}{% endblock %}
<script>
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
</script>
{% block scripts %}{% endblock %}
</head>
<body class="min-vh-100 d-flex flex-column">
<div class="container-fluid">
<div class="row bg-info px-2">
<div clas="col-md-12 text-white">
{% if brand_logo %}
<img class="mb2 me-2" width="46" height="46" src="{{ brand_logo }}">
{% else %}
<svg class="bi mb-2 me-2 text-white" width="46" height="46" fill="currentColor">
<use xlink:href="{% static 'icons/bootstrap-icons.svg' %}#book" ></use>
</svg>
{% endif %}
<span class="display-6 font-weight-bold text-white me-2">{{ brand_name }}</span>
{% if subtitle %}
<span class="h2">{{ subtitle }}</h2>
{% endif %}
</div>
</div>
<div class="navbar navbar-expand-lg">
<div class="collapse navbar-collapse">
<ul class="navbar-nav mb-2 mb-lg-0 me-auto">
<li class="nav-item me-3">
<a class="nav-link" href="{% url 'tinywiki:home' %}">HOME</a>
</li>
{% if user_can_create_wiki_pages %}
<li class="nav-item me-3">
<a class="nav-link" href="{% url 'tinywiki:page-create' %}">{% translate "Create Page" %}</a>
</li>
{% endif %}
<li class="nav-item me-3">
<a class="nav-link" href="{% url 'tinywiki:toc' %}">{% translate "Wiki content" %}</a>
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0 list-group-horizontal">
{% if user.is_authenticated %}
<li class="nav-item">
<form method="POST" action="{% url 'account_logout' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="nav-link">{% translate "Log out" %}</button>
</form>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url "account_login" %}">{% translate "Log in" %}</a>
</li>
{% endif %}
</ul>
</div>
</div>
<div class="row h-100">
<div class="col-lg-3">
</div>
<div class="col-lg-6">
<main>
{% block content %}
<h1>It Works!</h1>
{% endblock %}
</main>
</div>
<div class="col-lg-3">
</div>
</div>
</div>
<footer class="bg-secondary text-white mt-auto">
<span class="ms-2">
Powered by TinyWiki
</span>
<span class="text-end">
<a class="text-white" href="{% url 'tinywiki:page' 'tw-license' %}"> &copy; 2025</a>
</span>
</footer>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.min.js"></script>
{% block extra_scripts %}{% endblock extra_scripts %}
</body>
</html>

View File

@@ -35,8 +35,52 @@ class WikiContentType(StrEnum):
def __repr__(self): def __repr__(self):
return f"<{self.__qualname__}: {self.value.upper()}>" return f"<{self.__qualname__}: {self.value.upper()}>"
WIKI_CONTENT_TYPES = ( WIKI_CONTENT_TYPES = (
WikiContentType.MARKDOWN, WikiContentType.MARKDOWN,
WikiContentType.BBCODE, WikiContentType.BBCODE,
) )
class WikiPageStatus(StrEnum):
IN_PROGRESS = "in_progress"
DRAFT = "draft"
PUBLISHED = "published"
@staticmethod
def from_string(string:str)->"WikiPageStatus":
mapping = {
WikiPageStatus.IN_PROGRESS.value: WikiPageStatus.IN_PROGRESS,
WikiPageStatus.DRAFT.value: WikiPageStatus.DRAFT,
WikiPageStatus.PUBLISHED: WikiPageStatus.PUBLISHED,
}
return mapping[string.lower()]
@property
def str_raw(self)->str:
mapping = {
WikiPageStatus.IN_PROGRESS: _("in progress"),
WikiPageStatus.DRAFT: _("draft"),
WikiPageStatus.PUBLISHED: _("published"),
}
return mapping[self]
@property
def str_lazy(self)->str:
return gettext_lazy(self.str_raw)
@property
def str(self)->str:
return gettext(self.str_raw)
def __str__(self):
return self.str
def __repr__(self):
return f"<{self.__qualname__}: {self.value.upper()}>"
WIKI_PAGE_STATUS = (
WikiPageStatus.IN_PROGRESS,
WikiPageStatus.DRAFT,
WikiPageStatus.PUBLISHED,
)

26
tinywiki/forms.py Normal file
View File

@@ -0,0 +1,26 @@
from django import forms
from .models import Page,Image
class PageForm(forms.ModelForm):
class Meta:
model = Page
fields = [
'title',
'slug',
'status_data',
'content_type_data',
'content',
]
class PageAdminForm(forms.ModelForm):
class Meta:
model = Page
fields = [
'title',
'author',
'slug',
'status_data',
'content_type_data',
'content',
]

View File

@@ -1,6 +1,7 @@
# Generated by Django 5.2.4 on 2025-09-14 13:31 # Generated by Django 5.2.4 on 2025-09-15 00:26
import django.db.models.deletion import django.db.models.deletion
import tinywiki.models
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -22,8 +23,8 @@ class Migration(migrations.Migration):
('alt', models.CharField(max_length=511, verbose_name='alternative text')), ('alt', models.CharField(max_length=511, verbose_name='alternative text')),
('description', models.CharField(blank=True, max_length=1023, null=True, verbose_name='description')), ('description', models.CharField(blank=True, max_length=1023, null=True, verbose_name='description')),
('image', models.ImageField(upload_to='tinywiki/img', verbose_name='image file')), ('image', models.ImageField(upload_to='tinywiki/img', verbose_name='image file')),
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='upladed at')), ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tinywiki_image_uploads', to=settings.AUTH_USER_MODEL, verbose_name='uploaded by')), ('uploaded_by', models.ForeignKey(default=tinywiki.models.get_tinywiki_default_user, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tinywiki_image_uploads', to=settings.AUTH_USER_MODEL, verbose_name='uploaded by')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@@ -32,13 +33,14 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')), ('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')),
('title', models.CharField(max_length=255, verbose_name='title')), ('title', models.CharField(max_length=255, verbose_name='title')),
('status_data', models.CharField(default='', max_length=15, verbose_name='status')),
('content_type_data', models.CharField(choices=[('markdown', 'Markdown'), ('bbcode', 'BBCode')], default='bbcode', verbose_name='content type')), ('content_type_data', models.CharField(choices=[('markdown', 'Markdown'), ('bbcode', 'BBCode')], default='bbcode', verbose_name='content type')),
('content', models.TextField(verbose_name='Page content')), ('content', models.TextField(verbose_name='Page content')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('last_edited_at', models.DateTimeField(auto_now=True, verbose_name='last edited at')), ('last_edited_at', models.DateTimeField(auto_now=True, verbose_name='last edited at')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tinywiki_athors', to=settings.AUTH_USER_MODEL, verbose_name='author')), ('author', models.ForeignKey(default=tinywiki.models.get_tinywiki_default_user, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tinywiki_athors', to=settings.AUTH_USER_MODEL, verbose_name='author')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tinywiki_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')), ('created_by', models.ForeignKey(default=tinywiki.models.get_tinywiki_default_user, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tinywiki_created', to=settings.AUTH_USER_MODEL, verbose_name='created by')),
('last_edited_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tinywiki_last_edited', to=settings.AUTH_USER_MODEL, verbose_name='last edited by')), ('last_edited_by', models.ForeignKey(default=tinywiki.models.get_tinywiki_default_user, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tinywiki_last_edited', to=settings.AUTH_USER_MODEL, verbose_name='last edited by')),
], ],
), ),
] ]

View File

@@ -9,18 +9,90 @@ class Migration(migrations.Migration):
def init_tinywiki_user(apps,schema_editor): def init_tinywiki_user(apps,schema_editor):
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
user = get_user_model.objects.create_user(**settings.TINYWIKI_USER_CONFIG) user = get_user_model().objects.create_user(**settings.TINYWIKI_USER_CONFIG)
def init_tinywiki_groups_and_permissions(apps,schema_editor):
from ..models import Page
from django.contrib.auth.models import Group,Permission
from django.contrib.contenttypes.models import ContentType
PERMISSIONS = [
'tinywiki-read-all',
'tinywiki-delete',
'tinywiki-create',
'tinywiki-create-system',
'tinywiki-edit',
'tinywiki-edit-system',
'tinywiki-edit-all',
'tinywiki-delete-all',
'tinywiki-delete-system',
]
GROUPS = [
('tinywiki-moderator',('tinywiki-read-all',
'tinywiki-delete-all',
'tinywiki-edit-all',
'tinywiki-create')),
('tinywiki-author',('tinywiki-create',
'tinywiki-edit',
'tinywiki-delete')),
('tinywiki-reader',('tinywiki-read-all',)),
('tinywiki-admin',('tinywiki-read-all',
'tinywiki-create',
'tinywiki-create-system',
'tinywiki-delete-all',
'tinywiki-delete-system',
'tinywiki-edit-all',
'tinywiki-edit-system'))
]
perm_mapping = {}
content_type = ContentType.objects.get_for_model(Page)
for perm in PERMISSIONS:
permission = Permission.objects.create(codename=perm,content_type=content_type)
perm_mapping[perm] = permission
for grp,perms in GROUPS:
group = Group.objects.create(name=grp)
for perm in perms:
group.permissions.add(perm_mapping[perm])
def init_default_pages(apps,schema_editor)->None: def init_default_pages(apps,schema_editor)->None:
from ..models import Page,Image from ..models import Page,Image
#TODO from ..enums import WikiContentType,WikiPageStatus
from pathlib import Path
import json
page_path = Path(__file__).resolve().parent / "pages"
json_file = page_path / "pages.json"
if json_file.is_file():
with open(json_file,"rt",encoding="utf-8") as ifile:
data=json.loads(ifile.read())
for slug,spec in data.items():
filename = page_path / spec['file']
with open(filename,"rt",encoding="utf-8") as ifile:
content = ifile.read()
Page.objects.create(slug=slug,
title=spec['title'],
status_data=WikiPageStatus.from_string(spec['status']).value,
content_type_data=WikiContentType.from_string(spec['content_type']).value,
content=content)
def init_user_pages(apps,schema_edit)->None: def init_user_pages(apps,schema_edit)->None:
from ..models import Page,Image from ..models import Page,Image
#TODO #TODO
operations = [ operations = [
migrations.RunPython(init_tinywiki_groups_and_permissions),
migrations.RunPython(init_tinywiki_user), migrations.RunPython(init_tinywiki_user),
migrations.RunPython(init_default_pages) migrations.RunPython(init_default_pages),
] ]

View File

@@ -0,0 +1,23 @@
[h2]The MIT License (MIT)[/h2]
[h3]Copyright [copy] 2011-2025 The Bootstrap Authors[/h3]
[p]Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
[ul][li]
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.[/li][/ul]
[/p]
[p][b]THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.[/b][/p]

View File

@@ -0,0 +1,10 @@
[h2]Copyright [copy] 2025 Christian Moser[/h2]
[p]Redistribution and use in source and binary forms, with or without modification,are permitted provided that the following conditions are met:[/p]
[ol]
[li]Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.[/li]
[li]Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.[/li]
[/ol]
[p][b]THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.[/b][/p]

View File

@@ -0,0 +1,14 @@
{
"tw-license": {
"title": "TinyWiki License",
"content_type":"bbcode",
"status":"published",
"file":"license.bbcode"
},
"tw-bootstrap-license": {
"title": "Bootstrap License",
"content_type": "bbcode",
"status":"published",
"file":"bs-license.bbcode"
}
}

View File

@@ -4,10 +4,21 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils.safestring import mark_safe,SafeText from django.utils.safestring import mark_safe,SafeText
from django.contrib.auth import get_user_model
from tinywiki.enums import WIKI_CONTENT_TYPES, WikiContentType from tinywiki.enums import WIKI_CONTENT_TYPES, WIKI_PAGE_STATUS, WikiContentType, WikiPageStatus
import markdown import markdown
import bbcode from .parser import parse_bbcode
from . import settings
import tinywiki
def get_tinywiki_default_user():
UserModel = get_user_model()
try:
user = UserModel.objects.get(**settings.TINYWIKI_USER_LOOKUP)
except UserModel.DoesNotExist:
user = UserModel.objects.filter(is_superuser=True).order_by('pk')[0]
return user
class Page(models.Model): class Page(models.Model):
slug = models.SlugField(_("slug"), slug = models.SlugField(_("slug"),
@@ -20,11 +31,16 @@ class Page(models.Model):
null=False, null=False,
blank=False) blank=False)
author = models.ForeignKey(get_user_model(), author = models.ForeignKey(get_user_model(),
on_delete=models.SET_NULL, on_delete=models.SET_DEFAULT,
default=get_tinywiki_default_user,
verbose_name=_("author"), verbose_name=_("author"),
null=True,
blank=True,
related_name="tinywiki_athors") related_name="tinywiki_athors")
status_data = models.CharField(_("status"),
choices=[(i.value,i.str_lazy) for i in WIKI_PAGE_STATUS],
max_length=15,
null=False,
blank=False,
default=WikiPageStatus.IN_PROGRESS.value)
content_type_data = models.CharField(_("content type"), content_type_data = models.CharField(_("content type"),
choices=[(i.value,i.str_lazy) for i in WIKI_CONTENT_TYPES], choices=[(i.value,i.str_lazy) for i in WIKI_CONTENT_TYPES],
default=WikiContentType.BBCODE.value) default=WikiContentType.BBCODE.value)
@@ -37,31 +53,49 @@ class Page(models.Model):
created_at = models.DateTimeField(_("created at"), created_at = models.DateTimeField(_("created at"),
auto_now_add=True) auto_now_add=True)
created_by = models.ForeignKey(get_user_model(), created_by = models.ForeignKey(get_user_model(),
on_delete=models.SET_NULL, on_delete=models.SET_DEFAULT,
verbose_name=_("created by"), verbose_name=_("created by"),
null=True, default=get_tinywiki_default_user,
blank=True,
related_name="tinywiki_created") related_name="tinywiki_created")
last_edited_at = models.DateTimeField(_("last edited at"), last_edited_at = models.DateTimeField(_("last edited at"),
auto_now=True) auto_now=True)
last_edited_by = models.ForeignKey(get_user_model(), last_edited_by = models.ForeignKey(get_user_model(),
on_delete=models.SET_NULL, on_delete=models.SET_DEFAULT,
verbose_name=_("last edited by"), verbose_name=_("last edited by"),
null=True, default=get_tinywiki_default_user,
blank=True,
related_name="tinywiki_last_edited") related_name="tinywiki_last_edited")
@property @property
def content_type(self)->WikiContentType: def content_type(self)->WikiContentType:
return WikiContentType.from_string(self.content_type_data) return WikiContentType.from_string(self.content_type_data)
@content_type.setter
def content_type(self,content_type:str|WikiContentType):
if isinstance(content_type,str):
self.content_type_data = WikiContentType.from_string(content_type).value
elif isinstance(content_type,WikiContentType):
self.content_type_data = content_type.value
else:
raise TypeError("content_type")
@property
def status(self)->WikiPageStatus:
return WikiPageStatus.from_string(self.status_data)
@status.setter
def status(self,status:str|WikiPageStatus):
if isinstance(status,str):
self.status_data = WikiPageStatus.from_string(status).value
elif isinstance(status,WikiPageStatus):
self.status_data = status.value
else:
raise TypeError("status")
@property @property
def html_content(self)->SafeText|str: def html_content(self)->SafeText|str:
if self.content_type == WikiContentType.MARKDOWN: if self.content_type == WikiContentType.MARKDOWN:
return mark_safe(markdown.markdown(self.content)) return mark_safe(markdown.markdown(self.content))
elif self.content_type == WikiContentType.BBCODE: elif self.content_type == WikiContentType.BBCODE:
return mark_safe(bbcode.render_html(self.content)) return mark_safe(parse_bbcode(self.content))
return self.content return self.content
@@ -81,10 +115,9 @@ class Image(models.Model):
upload_to="tinywiki/img") upload_to="tinywiki/img")
uploaded_by = models.ForeignKey(get_user_model(), uploaded_by = models.ForeignKey(get_user_model(),
on_delete=models.SET_NULL, on_delete=models.SET_DEFAULT,
verbose_name=_("uploaded by"), verbose_name=_("uploaded by"),
null=True, default=get_tinywiki_default_user,
blank=True,
related_name="tinywiki_image_uploads") related_name="tinywiki_image_uploads")
uploaded_at = models.DateTimeField(_("uploaded at"), uploaded_at = models.DateTimeField(_("uploaded at"),
auto_now_add=True) auto_now_add=True)

View File

@@ -0,0 +1,4 @@
from .bbcode import PARSER as BBCODE_PARSER
def parse_bbcode(text:str):
return BBCODE_PARSER.format(text)

View File

@@ -0,0 +1,28 @@
import bbcode
from . import formatters
PARSER = bbcode.Parser(newline="\n",escape_html=True)
def _():
for i in formatters.SIMPLE_FORMATTERS:
if len(i) == 0:
continue
if len(i) == 1:
kwargs = {}
else:
kwargs = i[1]
PARSER.add_simple_formatter(*i[0],**kwargs)
for i in formatters.FORMATTERS:
if len(i) == 0:
continue
if len(i) == 1:
kwargs = {}
else:
kwargs = i[1]
PARSER.add_formatter(*i[0],**kwargs)
_()
del _

View File

@@ -0,0 +1,35 @@
from .text_formatters import (
render_codeblock,
render_url,
render_list_item,
render_ordered_list,
render_unordered_list,
render_paragraph,
render_image,
render_wiki_image,
render_wiki_link,
render_wiki_url,
)
from .simple_formatters import (
SIMPLE_HEADER_FORMATTERS,
)
# a list of tuples containig an tuple args and a dict of kwargs
SIMPLE_FORMATTERS=[
*SIMPLE_HEADER_FORMATTERS,
]
#a list of tuples containing an tuple of args and a dict of kwargs
FORMATTERS=[
(('url',render_url),{'strip':True,'swallow_trailing_newline':True,'same_tag_closes':True}),
(('wiki-url',render_wiki_url),{'strip':True,'swallow_trailing_newline':True,'same_tag_closes':True}),
(('wiki',render_wiki_link),{'strip':True,'swallow_tailin_newline':True,'standalone':True}),
(('codeblock',render_codeblock),{'strip':False,'swallow_trailing_newline':False,'same_tag_closes':True}),
(('ol',render_ordered_list),{}),
(('ul',render_unordered_list),{}),
(('li',render_list_item),{}),
(('p',render_paragraph),{}),
(('image',render_image),{}),
(('wiki-image',render_wiki_image),{'standalone':True})
]

View File

@@ -0,0 +1,12 @@
SIMPLE_HEADER_FORMATTERS = [
(('h1',"<h1>%(value)s</h1>"),{}),
(('h2',"<h2>%(value)s</h2>"),{}),
(('h3',"<h3>%(value)s</h3>"),{}),
(('h4',"<h4>%(value)s</h4>"),{}),
(('h5',"<h5>%(value)s</h5>"),{}),
(('h6',"<h6>%(value)s</h6>"),{}),
(('copy',"&copy;"),{'standalone':True}),
(('reg',"&reg;"),{'standalone':True}),
(('trade',"&trade;"),{'standalone':True}),
]

View File

@@ -0,0 +1,221 @@
from django_project.settings import STATIC_URL
from django.urls import reverse
from django.template.loader import render_to_string
from django.utils.translation import gettext as _
from ... import settings
from ... import models
def render_url(tag_name:str,value,options,parent,context):
try:
url = options['url']
except KeyError:
url = value
if '://' not in url:
url = "http://" + url
if settings.USE_BOOTSTRAP:
return f"<a href=\"{url}\" class=\"icon-link icon-link-hover\" referrer-policy=\"no-referrer\" rel=\"noreferrer noopener\">{value}{render_to_string('tinywiki/icons/box-arrow-up-right.svg')}</a>"
return f"<a href=\"{url}\" referrer-policy=\"no-referrer\" rel=\"noreferrer noopener\">{value}</a>"
def render_wiki_url(tag_name,value,options,parent,context):
if tag_name in options:
url = reverse("tinywiki:page",kwargs={'slug':options[tag_name]})
slug=options['tag_name']
try:
page = models.Page.objects.get(slug=slug)
except models.Page.DoesNotExist:
page = None
else:
url = reverse('tinywiki:home')
slug=None
if settings.USE_BOOTSTRAP:
if page:
if page.slug.startswith('tw-'):
svg=render_to_string('tinywiki/icons/journal.svg')
elif page.slug:
svg=render_to_string('tinywiki/icons/book.svg')
else:
svg=render_to_string('tinywiki/icons/file-earmark-x')
return f"<a href=\"{url}\" class=\"icon-link icon-link-hover\">{value}{svg}</a>"
return f"<a href=\"{url}\">{value}</a>"
def render_wiki_link(tag_name,value,options,parent,context):
if tag_name in options:
slug = options['tag_name']
try:
page = models.Page.objects.get(slug=slug)
title = page.title
if slug.starts_with('tw-'):
svg = "tinywiki/icons/journal.svg"
else:
svg = "tinywiki/icons/book.svg"
except:
page = None
title = _("Page not found")
svg_template = "tinywiki/icons/file-earmark-x.svg"
url = reverse("tinywiki:page",kwargs={'slug':slug})
else:
slug = None
title = _("Home")
url = reverse("tinywiki:home")
svg_template = "tinywiki/icons/house.svg"
if settings.USE_BOOTSTRAP:
return f"<a href=\"{url}\" class=\"icon-link icon-link-hover\">{value}{render_to_string(svg_template)}</a>"
return f"<a href=\"{url}\">{value}</a>"
def render_codeblock(tag_name:str,value,options,parent,context)->str:
if 'codeblock' in options:
return f"<pre><code class=\"language-{options['codeblock']}\">{value}</pre></code>"
return f"<pre><code>{value}</pre></code>"
def render_ordered_list(tag_name:str,value,options,parent,context)->str:
return f"<ol>{value}</ol>"
def render_unordered_list(tag_name:str,value,options,parent,context)->str:
return f"<ul>{value}</ul>"
def render_list_item(tag_name:str,value,options,parent,context)->str:
return f"<li>{value}</li>"
def render_paragraph(tag_name:str,value,options,parent,context):
if settings.USE_BOOTSTRAP:
return f"<p style=\"text-align:justify;\">{value}</p>"
return f"<p>{value}</p>"
def render_image(tag_name:str,value,options,parent,context):
if tag_name not in options:
return ""
if 'alt' in options:
alt=options['alt']
else:
alt=""
if settings.USE_BOOTSTRAP:
classes=["img-fluid","figure-img","rounded"]
fig_classes=["figure","my-1"]
styles=[]
fig_styles=[]
else:
styles=["max-width:100%;"]
classes=[]
fig_classes=[]
fig_styles=[]
if 'width' in options:
_w = options['width']
if _w.endswith('px'):
fig_styles.append(f"width:{_w};")
else:
if _w.endswith('%'):
_w = _w[:-1]
if _w.isdigit():
_w=int(_w)
if _w > 100:
_w = 100
if settings.USE_BOOTSTRAP:
if 1 < int(_w) <= 25:
width = 25
else:
width = ((_w // 25) * 25)
fig_classes.append(f'w-{width}')
else:
fig_styles.append(f"width:{_w}%;")
if "position" in options:
pos = options['position']
if settings.USE_BOOTSTRAP:
if pos == "left" or pos=="start":
fig_classes += ["float-start","me-2"]
elif pos == "right" or pos == "end":
fig_classes += ["float-end","ms-2"]
elif pos == "center":
fig_classes += ["mx-auto","d-block"]
if styles:
style=f"style=\"{"".join(styles)}\""
else:
style=""
if fig_styles:
fig_style=f'style="{"".join(fig_styles)}"'
else:
fig_style=""
if settings.USE_BOOTSTRAP:
return f'<figure class="{" ".join(fig_classes)} {fig_style}"><img src="{options[tag_name]}" class="{' '.join(classes)}" alt="{alt}" {style}><figcaption class="figure-caption text-end">{value}</figcaption></figure>'
else:
return f'<figure {fig_style}><img src="{options[tag_name]}" {style}><figcaption>{value}</figcaption></figure>'
def render_wiki_image(tag_name:str,value,options,parent,context):
if tag_name not in options:
return ""
try:
image = models.Image.objects.get(slug=options[tag_name])
except models.Image.DoesNotExist:
return ""
if settings.USE_BOOTSTRAP:
classes=["img-fluid","figure-img","rounded"]
fig_classes=["figure","my-1"]
styles=[]
fig_styles=[]
else:
styles=["max-width:100%;"]
classes=[]
fig_classes=[]
fig_styles=[]
if 'width' in options:
_w = options['width']
if _w.endswith('px'):
fig_styles.append(f"width:{_w};")
else:
if _w.endswith('%'):
_w = _w[:-1]
if _w.isdigit():
_w=int(_w)
if _w > 100:
_w = 100
if settings.USE_BOOTSTRAP:
if 1 < int(_w) <= 25:
width = 25
else:
width = ((_w // 25) * 25)
fig_classes.append(f'w-{width}')
else:
fig_styles.append(f"width:{_w}%;")
if "position" in options:
pos = options['position']
if settings.USE_BOOTSTRAP:
if pos == "left" or pos=="start":
fig_classes += ["float-start","me-2"]
elif pos == "right" or pos == "end":
fig_classes += ["float-end","ms-2"]
elif pos == "center":
fig_classes += ["mx-auto","d-block"]
if styles:
style=f"style=\"{"".join(styles)}\""
else:
style=""
if fig_styles:
fig_style=f'style="{"".join(fig_styles)}"'
else:
fig_style=""
if settings.USE_BOOTSTRAP:
return f'<figure class="{" ".join(fig_classes)}" {fig_style}><img src="{image.image.url}" alt="{image.alt}" class="{' '.join(classes)}" {style}><figcaption class="figure-caption text-end">{image.description}</figcaption></figure>'
else:
return f'<figure {fig_style}><img src="{image.image.url}" alt="{image.alt}" {style}><figcaption>{image.description}</figcaption></figure>'

View File

@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django_project.settings import STATIC_URL
TINYWIKI_USER_CONFIG = getattr(settings, TINYWIKI_USER_CONFIG = getattr(settings,
"TINYWIKI_USER_CONFIG", "TINYWIKI_USER_CONFIG",
{ {
@@ -12,8 +14,21 @@ TINYWIKI_USER_LOOKUP = getattr(settings,
"TINYWIKI_USER_LOOKUP", "TINYWIKI_USER_LOOKUP",
{'username':"TinyWiki"}) {'username':"TinyWiki"})
TINYWIKI_BRAND_LOGO = getattr(settings,
"TINYWIKI_BRAND_LOGO",
None)
TINYWIKI_BRAND_NAME = getattr(settings,
"TINYWIKI_BRAND_NAME",
"TinyWiki")
TINYWIKI_BOOSTRAP_TAGS = { TINYWIKI_BOOSTRAP_TAGS = {
'img': { 'img': {
'class':'img-fluid', 'class':'img-fluid',
} }
} }
TINYWIKI_BASE_TEMPLATE = getattr(settings,
"TINYWIKI_BASE_TEMPLATE",
"tinywiki/base.html")
USE_BOOTSTRAP = getattr(settings,"USE_BOOTSTRAP",False)

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-book" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>

After

Width:  |  Height:  |  Size: 772 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
{% load i18n %}
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TinyWiki</title>
{% block extra_css %}{% endblock %}
{% block scripts %}{% endblock %}
</head>
<body>
<header>
<span style="font-size:2.5rem;">TinyWiki</span>
</header>
<nav>
<ul>
<li><a href="{% url 'tinywiki:home' %}">{% translate "HOME" %}</a></li>
{% if user_can_create_wiki_pages %}
<li><a href="{% url 'tinywiki:page-create' %}">{% translate "Create Page" %}</a></li>
{% endif %}
<li><a href="{% url 'tinywiki:toc' %}">{% translate "Wiki content" %}</a>
</ul>
</nav>
<main class="content">
{% block content %}
<h1>It Works!</h1>
{% endblock %}
</main>
{% block extra_scripts %}{% endblock extra_scripts %}
</body>
</html>

View File

@@ -0,0 +1,44 @@
{% extends base_template %}
{% load i18n static %}
{% block content %}
<h1>{% translate "Table of Contents" %}</h1>
{% for toc_section,pages_1,pages_2 in toc %}
<h2>{{ toc_section }}</h2>
<div class="row gx-4 mb-2">
<div class="col-lg-6">
<ul style="list-style-type:none;" class="mb-0 ps-0">
{% for page in pages_1 %}
<li>
<a class="icon-link icon-link-hover" href="{% url 'tinywiki:page' page.slug %}">
{{ page.title }}
{% if page.is_system %}
<svg class="bi"><use xlink:href="{% static 'icons/bootstrap-icons.svg' %}#journal" ></use></svg>
{% else %}
<svg class="bi"><use xlink:href="{% static 'icons/bootstrap-icons.svg' %}#book" ></use></svg>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="col-lg-6">
<ul style="list-style-type:none;" class="mt-0 ps-0">
{% for page in pages_2 %}
<li>
<a class="icon-link icon-link-hover" href="{% url 'tinywiki:page' page.slug %}">
{{ page.title }}
{% if page.is_system %}
<svg class="bi"><use xlink:href="{% static 'icons/bootstrap-icons.svg' %}#journal" ></use></svg>
{% else %}
<svg class="bi"><use xlink:href="{% static 'icons/bootstrap-icons.svg' %}#book" ></use></svg>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor%}
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends base_template %}
{% load i18n %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/default.min.css">
{% endblock %}
{% block content %}
{% if page %}
<h1>{{ page.title }}</h1>
{{ page.html_content }}
{% else %}
<h1>Welcome to TinyWiki</h1>
<p>{% blocktranslate %}You are seeing this welcome page because there is no Welcome page
configured for your Wiki. To configure a welcome page create a new page with the
slug <i>tw-home</i> and put the content for your welcome-page there.{% endblocktranslate %}</p>
<p>{% blocktranslate %}You can use Markdown or BBCode to write your pages. If you don't know
Markdown read the <a href="#">Guide for Markdown used by TinyWiki</a>. Or if you want to use
BBCode there is a <a href="#">Guide for BBCode used by TinyWiki</a> too.{% endblocktranslate %}
</p>
{% endif %}
{% endblock content %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
{% endblock %}
{% block extra_scripts %}
<script>hljs.highlightAll();</script>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends base_template %}
{% load i18n %}
{% block content %}
<h1>{% translate "Table of contents" %}</h1>
{% for toc_section,pages_1,pages_2 in toc %}
<h2>{{ toc_section }}</h2>
<ul>
{% for page in pages_1 %}
<li><a href="{% url 'tinywiki:page' page.slug %}">{{ page.title }}</a></li>
{% endfor %}
{% for page in pages_2 %}
<li><a href="{% url 'tinywiki:page' page.slug %}">{{ page.title }}</a></li>
{% endfor %}
</ul>
{% endfor%}
{% endblock %}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-book" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>

After

Width:  |  Height:  |  Size: 772 B

View File

@@ -0,0 +1,4 @@
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-box-arrow-up-right\" viewBox=\"0 0 16 16\">
<path fill-rule=\"evenodd\" d=\"M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5\"/>
<path fill-rule=\"evenodd\" d=\"M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z\"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-x" viewBox="0 0 16 16">
<path d="M6.854 7.146a.5.5 0 1 0-.708.708L7.293 9l-1.147 1.146a.5.5 0 0 0 .708.708L8 9.707l1.146 1.147a.5.5 0 0 0 .708-.708L8.707 9l1.147-1.146a.5.5 0 0 0-.708-.708L8 8.293z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-journal" viewBox="0 0 16 16">
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"/>
<path d="M1 5v -.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -0,0 +1,26 @@
{% extends base_template %}
{% load i18n %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/default.min.css">
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
{% endblock %}
{% block extra_scripts %}
<script>hljs.highlightAll();</script>
{% endblock %}
{% block content %}
<div class="text-end">
{% if user_can_edit_wiki_page %}
<a class="btn btn-primary" href="{% url 'tinywiki:page-edit' slug=page.slug %}">{% translate "Edit Page" %}</a>
{% endif %}
{% if user_can_delete_wiki_page %}
<button class="btn btn-danger" type="button">Delete Page</button>
{% endif %}
</div>
<h1>{{ page.title }}</h1>
{{ page.html_content }}
{% endblock content %}

View File

@@ -0,0 +1,16 @@
{% extends "tinywiki/page/model-base.html" %}
{% load i18n %}
{% block title %}{% translate "Create a new Wiki page" %}{% endblock title%}
{% block form_buttons %}
{% if use_bootstrap %}
<div class="text-end">
<button class="btn btn-primary">{% translate "Create Page" %}</button>
<a class="btn btn-secondary" href="{% url 'tinywiki:home' %}">{% translate "Cancel" %}</a>
</div>
{% else %}
<button type="submit">{% translate "Create Page" %}</button>
<a class="button button-secondary" href="{% url 'tinywiki:home' %}">{% translate "Cancel" %}</a>
{% endif %}
{% endblock form_buttons %}

View File

@@ -0,0 +1,16 @@
{% extends "tinywiki/page/model-base.html" %}
{% load i18n %}
{% block title %}{% translate "Edit Wiki Page" %}{% endblock title%}
{% block form_buttons %}
{% if use_bootstrap %}
<div class="text-end">
<button type="submit" class="btn btn-primary">{% translate "Save Changes" %}</button>
<a class="btn btn-secondary" href="{% url "tinywiki:page" form.instance.slug %}">{% translate "Cancel" %}</a>
</div>
{% else %}
<button type="submit">{% translate "Save Changes" %}</button>
<a class="button button-secondary" href="{% url "tinywiki:page" form.instance.slug %}">{% translate "Cancel" %}</a>
{% endif %}
{% endblock form_buttons %}

View File

@@ -0,0 +1,107 @@
{% extends base_template %}
{% load i18n widget_tweaks %}
{% block content %}
<h1>{% block title %}{% endblock title%}</h1>
<form method="POST"
id="wiki-page-form"
action="{% if create %}{% url "tinywiki:page-create" %}{% else %}{% url "tinywiki:page-edit" slug=form.instance.slug|default:"1" %}{% endif %}">
{% csrf_token %}
{% if use_bootstrap %}
<div class="form-floating mb-2">
{% with "form-control form-control-lg "|add:title_extra as title_class %}
{% render_field form.title class=title_class placeholder="{{ form.title.label }}" %}
{% endwith %}
<label for="{{ form.title.auto_id }}">{{ form.title.label }}</label>
</div>
<div class="form-floating mb-2">
{% with "form-control form-control-sm "|add:slug_extra as slug_class%}
{% render_field form.slug class=slug_class placeholder="{{ form.slug.label }}" %}
{% endwith %}
<label for="{{ form.slug.auto_id }}">{{ form.slug.label }}</label>
</div>
<div class="row">
<div class="col-md-6 mb-2">
<div class="form-floating mb-2">
{% with "form-select form-select-sm "|add:content_type_extra as content_type_class %}
{% render_field form.content_type_data class=content_type_class %}
{% endwith %}
<label for="{{ for.content_type_data.auto_id }}">{{ form.content_type_data.label }}</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-2">
{% with "form-select form-select-sm "|add:status_extra as status_class %}
{% render_field form.status_data class=status_class %}
{% endwith %}
<label for="{{ for.status_data.auto_id }}">{{ form.status_data.label }}</label>
</div>
</div>
</div>
<div class="form-floating mb-3">
{% with "form-control h-100 "|add:content_extra as content_class %}
{% render_field form.content class=content_class rows="25"%}
{% endwith %}
<label for="{{ form.content.auto_id }}">{{ form.content.label }}</label>
</div>
<div class="text-end">
</div>
{% else %}
<p>
{% with title_extra|default:"" as title_class %}
{{ form.title.label_tag }}{% render_field form.title class=Title_class %}
{% endwith %}
</p>
<p>
{% with slug_extra|default:"" as slug_class %}
{{ form.slug.label_tag }}{% render_field form.slug class=slug_class %}
{% endwith %}
</p>
<p>
{% with content_type_extra|default:"" as content_type_class %}
{{ form.content_type_data.label_tag }}{% render_field form.content_type_data class=content_type_class %}
{% endwith %}
</p>
<p>
{% with status_extra|default:"" as status_class %}
{{ form.status_data.label_tag }}{% render_field form.status_data class=status_class %}
{% endwith %}
</p>
<p>
{% with content_extra|default:"" as content_class %}
{{ form.content.label_tag }}{% render_field form.content class=content_class cols=80 rows=30 %}
{% endwith %}
</p>
{% endif %}
{% block form_buttons %}{% endblock form_buttons %}
</form>
{% endblock content%}
{% block extra_scripts %}
<script>
document.getElementById("id_content").addEventListener('keydown',function(e) {
if (e.key == "Tab") {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
console.log(this.value);
// set textarea value to: text before caret + 4 spaces + text after caret
this.value = this.value.substring(0,start) + " " + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 4;
}
});
document.addEventListener('keydown',function(e) {
if (e.ctrlKey && e.key == "s") {
e.preventDefault();
var form = document.getElementById('wiki-page-form');
form.action = form.action + "?save=1";
form.submit();
}
});
</script>
{% endblock extra_scripts %}

View File

@@ -0,0 +1,24 @@
{% extends base_template %}
{% load i18n %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/default.min.css">
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
{% endblock %}
{% block extra_scripts %}
<script>hljs.highlightAll();</script>
{% endblock %}
{% block content %}
{% if user_can_edit_wiki_page %}
<a href="{% url 'tinywiki:page-edit' slug=page.slug %}">{% translate "Edit Page" %}</a>
{% endif %}
{% if user_can_delete_wiki_page %}
<a href="#">Delete Page</a>
{% endif %}
<h1>{{ page.title }}</h1>
{{ page.html_content }}
{% endblock content %}

View File

@@ -1,7 +1,13 @@
from django.urls import path from django.urls import path
from .views import *
app_name = "tinywiki" app_name = "tinywiki"
urlpatterns = [ urlpatterns = [
path("",HomeView.as_view(),name="home"),
path("toc/",TocView.as_view(),name="toc"),
path("page/<slug:slug>/",PageView.as_view(),name="page"),
path("page-create/",PageCreateView.as_view(),name="page-create"),
path("page/<slug:slug>/edit/",PageEditView.as_view(),name='page-edit'),
] ]

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -0,0 +1,2 @@
from .home import *
from .page import *

43
tinywiki/views/base.py Normal file
View File

@@ -0,0 +1,43 @@
from .. import settings
from django.views import View as DjangoView
from django.views.generic import FormView as DjangoFormView
from typing import Any
class Base:
base_template_name = settings.TINYWIKI_BASE_TEMPLATE
@classmethod
def get_base_template_name(cls)->str:
return cls.base_template_name
@classmethod
def get_template_name(cls)->str:
return cls.template_name
def get_tinywiki_context_data(self):
create_pages = False
if self.request.user.is_authenticated:
if self.request.user.is_staff or self.request.user.has_perm('tinywiki.tinywiki-create'):
create_pages = True
return {
'brand_logo': settings.TINYWIKI_BRAND_LOGO,
'brand_name': settings.TINYWIKI_BRAND_NAME,
'base_template': self.get_base_template_name(),
'use_bootstrap': settings.USE_BOOTSTRAP,
'user_can_create_wiki_pages':create_pages
}
class View(Base,DjangoView):
template_name = "tinywiki"
def get_context_data(self,**kwargs):
context = self.get_tinywiki_context_data()
context.update(kwargs)
return context
class FormView(Base,DjangoFormView):
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = self.get_tinywiki_context_data()
context.update(kwargs)
return super().get_context_data(**context)

94
tinywiki/views/home.py Normal file
View File

@@ -0,0 +1,94 @@
from curses.ascii import isalpha
from django.shortcuts import render
from tinywiki import settings
from .base import View
from django.http import HttpRequest,HttpResponse
from django.db.models.functions import Lower
import string
from ..models import Page
from ..enums import WikiPageStatus
from django.utils.translation import ngettext, gettext as _
# Create your views here.
class HomeView(View):
template_name = "tinywiki/home/home.html"
def get(self,request):
try:
page = Page.objects.get(slug='tw-home')
except Page.DoesNotExist:
page = None
return render(request,
self.get_template_name(),
self.get_context_data(page=page))
class TocView(View):
template_name = "tinywiki/home/wiki-content.html"
bs_template_name = "tinywiki/home/bs-wiki-content.html"
@classmethod
def get_template_name(cls):
if settings.USE_BOOTSTRAP:
return cls.bs_template_name
return cls.template_name
def get(self,request):
def mkdict(page:Page):
return {'slug':page.slug,'title':page.title, 'is_system':page.slug.startswith('tw-')}
user = self.request.user
if (user.is_staff or user.has_perm('page.read_all')):
pages = Page.objects.all()
else:
pages = Page.objects.filter(status_data=WikiPageStatus.PUBLISHED.value)
toc_mapping = {}
for page in pages:
first_char = page.title.lstrip()[0].upper()
if first_char.isdigit():
if '0-9' not in toc_mapping:
toc_mapping['0-9'] = [mkdict(page)]
else:
toc_mapping['0-9'].append(mkdict(page))
elif first_char in string.punctuation:
if '#' not in toc_mapping:
toc_mapping['#'] = [mkdict(page)]
else:
toc_mapping['#'].append(mkdict(page))
else:
if first_char not in toc_mapping:
toc_mapping[first_char] = [mkdict(page)]
else:
toc_mapping[first_char].append(mkdict(page))
toc = []
for key in sorted(toc_mapping.keys()):
toc_entries = toc_mapping[key]
count = len(toc_mapping[key])
split = (count + 1) // 2
pages_0 = toc_entries[:split]
if split >= count:
pages_1 = []
else:
pages_1 = toc_entries[split:]
if (key.startswith('0') or key == '#'):
toc_section = key
else:
toc_section = f"{key}..."
toc_section = ngettext("{toc_section} ({n} page)","{toc_section} ({n} pages)", count).format(
toc_section=toc_section,
n=count,
)
toc.append((toc_section,pages_0,pages_1))
return render(request,
self.get_template_name(),
self.get_context_data(toc=toc,subtitle=_("Table of Contents")))

261
tinywiki/views/page.py Normal file
View File

@@ -0,0 +1,261 @@
from django.shortcuts import render,get_object_or_404,redirect
from django.urls import reverse, reverse_lazy
from django.http import HttpRequest,HttpResponse
from django.contrib.auth.mixins import LoginRequiredMixin,UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext as _
from .. import settings
from ..models import Page
from .base import View,FormView
from ..forms import PageForm,PageAdminForm
class PageView(View):
template_name = "tinywiki/page/view.html"
bs_template_name = "tinywiki/page/bs-view.html"
@classmethod
def get_template_name(cls) -> str:
if settings.USE_BOOTSTRAP:
return cls.bs_template_name
return cls.template_name
def get_context_data(self,page,**kwargs):
can_edit_page = False
can_delete_page = False
user = self.request.user
if (user.is_staff):
can_edit_page = True
can_delete_page = True
elif page.slug.startswith('tw-'):
if user.has_perm('page.tinywiki-edit-system'):
can_edit_page = True
if user.has_perm('page.tinywiki-delete-system'):
can_delete_page = True
else:
if (user.has_perm('page.tinywiki-edit-all')
or (user.pk == page.author.pk and user.has_perm('page.tinywiki-edit'))):
can_edit_page = True
if (user.has_perm('page.tinywiki-delete-all')
or (user.pk == page.author.pk and user.has_perm('page.tinywiki-delete'))):
can_delete_page = True
kwargs.update({'page':page,
'user_can_edit_wiki_page':can_edit_page,
'user_can_delete_wiki_page':can_delete_page,
'subtitle':page.title})
return super().get_context_data(**kwargs)
def get(self,request:HttpRequest,slug:str)->HttpResponse:
page = get_object_or_404(Page,slug=slug)
return render(request,
self.get_template_name(),
self.get_context_data(page=page))
class PageCreateView(LoginRequiredMixin,UserPassesTestMixin,FormView):
template_name = "tinywiki/page/create.html"
form_class = PageForm
def test_func(self) -> bool:
if self.request.user.is_authenticated:
if self.request.user.is_staff:
return True
return self.request.user.has_perm('tinywiki.tinywiki-create')
return False
def get_context_data(self,**kwargs):
context = super().get_context_data(**kwargs)
context['create']=True
context.setdefault("title_extra","")
context.setdefault("slug_extra","")
context.setdefault("content_type_extra","")
context.setdefault("status_extra","")
context.setdefault("content_extra","")
return context
def form_invalid(self, form):
if 'slug' in form.errors:
slug_extra = "is-invalid"
else:
slug_extra = "is-valid"
if 'title' in form.errors:
title_extra = 'is-invalid'
else:
title_extra = 'is-valid'
if 'status_data' in form.errors:
status_extra = 'is-invalid'
else:
status_extra = 'is-valid'
if 'content_type_data' in form.errors:
content_type_extra = 'is-invalid'
else:
content_type_extra = 'is-valid'
if 'content' in form.errors:
content_extra = 'is-invalid'
else:
content_extra = 'is-valid'
return render(self.request,
self.get_template_name(),
self.get_context_data(
slug_extra=slug_extra,
title_extra=title_extra,
status_extra=status_extra,
content_type_extra=content_type_extra,
content_extra=content_extra,
))
def form_valid(self,form):
user = self.request.user
instance = form.save(commit=False)
if (instance.slug.startswith('tw-')
and not user.is_staff and
not user.has_perm('page.tinywiki-create-system')):
return render(self.request,
self.get_template_name(),
self.get_context_data(
slug_extra="is-invalid",
title_extra="is-valid",
status_extra="is-valid",
content_type_extra="is-valid",
content_extra="is-valid",
))
instance.author = user
instance.created_by = user
instance.last_edited_by = user
try:
form.save(commit=True)
if self.request.GET.get('save',"0") == "1":
return redirect(reverse("tinywiki:page-edit",kwargs={"slug":instance.slug}))
return redirect(reverse("tinywiki:page",kwargs={'slug':instance.slug}))
except:
return render(self.request,
self.get_template_name(),
self.get_context_data(
slug_extra="is-invalid",
title_extra="is-valid",
status_extra="is-valid",
content_type_extra="is-valid",
content_extra="is-valid",
))
class PageEditView(LoginRequiredMixin,UserPassesTestMixin,FormView):
template_name = "tinywiki/page/edit.html"
form_class = PageForm
def test_func(self) -> bool:
if self.request.user.is_authenticated:
if self.request.user.is_staff:
return True
return False
def get_context_data(self,**kwargs):
context = super().get_context_data(**kwargs)
context['create']=False
context.setdefault("title_extra","")
context.setdefault("slug_extra","")
context.setdefault("content_type_extra","")
context.setdefault("status_extra","")
context.setdefault("content_extra","")
return context
def form_invalid(self, form):
if 'slug' in form.errors:
slug_extra = "is-invalid"
else:
slug_extra = "is-valid"
if 'title' in form.errors:
title_extra = 'is-invalid'
else:
title_extra = 'is-valid'
if 'status_data' in form.errors:
status_extra = 'is-invalid'
else:
status_extra = 'is-valid'
if 'content_type_data' in form.errors:
content_type_extra = 'is-invalid'
else:
content_type_extra = 'is-valid'
if 'content' in form.errors:
content_extra = 'is-invalid'
else:
content_extra = 'is-valid'
return render(self.request,
self.get_template_name(),
self.get_context_data(
slug_extra=slug_extra,
title_extra=title_extra,
status_extra=status_extra,
content_type_extra=content_type_extra,
content_extra=content_extra,
))
def get(self,request,slug:str):
instance = get_object_or_404(Page,slug=slug)
user = request.user
if (instance.slug.startswith('tw-')
and not user.is_staff
and not user.has_perm('page.tinywiki-edit-system')):
raise PermissionDenied(_("Only staff users and wiki-admins are allowed to edit TinyWiki system pages!"))
if user.pk != instance.author.pk:
if not user.is_staff and not user.has_perm("page.tinywiki-edit-all"):
raise PermissionDenied()
else:
if not user.has_perm('page.tinywiki-edit-all') or not user.has_perm('page.tinywiki-edit'):
raise PermissionDenied()
self.instance = instance
return super().get(request)
def post(self,request,slug:str):
instance = get_object_or_404(Page,slug=slug)
user = request.user
if (instance.slug.startswith('tw-') and not user.is_staff):
raise PermissionDenied(_("Only staff users are allowed to edit TinyWiki system pages!"))
if user.pk != instance.author.pk:
if not user.is_staff and not user.has_perm("page.tinywiki-edit-all"):
raise PermissionDenied()
else:
if not user.has_perm('page.tinywiki-edit-all') or not user.has_perm('page.tinywiki-edit'):
raise PermissionDenied()
self.instance = instance
return super().post(request)
def get_form(self):
return self.get_form_class()(instance=self.instance,**self.get_form_kwargs())
def form_valid(self,form):
user = self.request.user
instance = form.save(commit=False)
instance.created_by = user
instance.last_edited_by = user
try:
form.save(commit=True)
if self.request.GET.get('save',"0") == "1":
return redirect(reverse("tinywiki:page-edit",kwargs={"slug":instance.slug}))
return redirect(reverse("tinywiki:page",kwargs={'slug':instance.slug}))
except:
return render(self.request,
self.get_template_name(),
self.get_context_data(slug_invalid=True))
class PageDeleteView(View):
template_name = "tinywiki/page/delete"
def get(self,request,slug):
return render()

View File

@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
from user.models import UserProfile from user.models import UserProfile
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
if settings.DEBUG and UserProfile.objects.count() == 0: if settings.DEBUG and UserProfile.objects.filter(is_superuser=True).count() == 0:
user = UserProfile.objects.create_superuser("debug-admin","debug-admin@example.com","Pa55w.rd") user = UserProfile.objects.create_superuser("debug-admin","debug-admin@example.com","Pa55w.rd")
account_emailaddress = EmailAddress.objects.create(user=user,email=user.email,verified=True,primary=True) account_emailaddress = EmailAddress.objects.create(user=user,email=user.email,verified=True,primary=True)