Compare commits

..

3 Commits

Author SHA1 Message Date
evilchili
68cfd0e7f6 formatting 2025-10-30 20:52:15 -07:00
evilchili
f6efbeb54a Add system user, prevent circular refs in author 2025-10-30 20:51:24 -07:00
evilchili
5fffbf59f5 Put root page on uri "", make parent() a method 2025-10-29 19:06:57 -07:00
7 changed files with 296 additions and 66 deletions

View File

@ -153,45 +153,41 @@ API_URI=/_/v1/
if uri.startswith(self.config.VIEW_URI): if uri.startswith(self.config.VIEW_URI):
uri = uri.replace(self.config.VIEW_URI, "", 1) uri = uri.replace(self.config.VIEW_URI, "", 1)
parent_uri = '' parent_uri = None
search_uri = '/'
page_name = '/'
if "/" in uri:
(parent_uri, page_name) = uri.rsplit("/", 1)
if parent_uri == 'Page':
parent_uri = '/'
search_uri = page_name
else:
search_uri = uri
elif uri:
parent_uri = "/"
search_uri = uri search_uri = uri
page_name = uri page_name = uri
self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}") if "/" in uri:
(parent_uri, page_name) = uri.rsplit("/", 1)
if parent_uri == "Page":
parent_uri = None
search_uri = page_name
else:
search_uri = uri
# self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_uri=}")
# self.log.debug("\n".join([f"{p.doc_id}: {p.uri}" for p in table.all()])) # self.log.debug("\n".join([f"{p.doc_id}: {p.uri}" for p in table.all()]))
page = table.get(where("uri") == search_uri, recurse=False) page = table.get(where("uri") == search_uri, recurse=False)
if not page: if not page:
# load the parent to check for write permissions # load the parent to check for write permissions
self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}") # self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}")
parent_table = table if "/" in parent_uri else self.db.Page parent_table = table if parent_uri and "/" in parent_uri else self.db.Page
parent = None parent = None
try: try:
self.log.debug(f"Loading parent with {parent_uri}") # self.log.debug(f"Loading parent with {parent_uri}")
parent = self.get_page(user, parent_table.name, uri=parent_uri) parent = self.get_page(user, parent_table.name, uri=parent_uri)
except Exception as e: except Exception as e:
self.log.debug(f"Error loading parent: {e}") self.log.debug(f"Error loading parent: {e}")
if not parent: if not parent:
raise MalformedRequestError("Page does not exist and neither does its parent.") raise MalformedRequestError(f"Page with uri '{search_uri}' does not exist and neither does its parent.")
if not self.authorize(user, parent, schema.Permissions.WRITE): if not self.authorize(user, parent, schema.Permissions.WRITE):
raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.") raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.")
page = getattr(schema, table.name)(
name=page_name, body=f"# {page_name}\nThis page does not exist", parent=parent obj = getattr(schema, table.name)
) page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent)
self.log.debug(f"Returning {page.doc_id}: {page.uri}")
# self.log.debug(f"Returning {page.doc_id}: {page.uri}")
return page return page
def get_page(self, user, table_name, doc_id=None, uri=None): def get_page(self, user, table_name, doc_id=None, uri=None):
@ -216,11 +212,8 @@ API_URI=/_/v1/
page = table.get(doc_id=doc_id) page = table.get(doc_id=doc_id)
if not page: if not page:
raise RecordNotFoundError(f"No record with {doc_id=} was found.") raise RecordNotFoundError(f"No record with {doc_id=} was found.")
elif uri:
page = self._get_or_create_page_by_uri(user, table, uri) page = self._get_or_create_page_by_uri(user, table, uri)
else:
self.log.error("No doc_id or uri.")
raise MalformedRequestError("Either a doc_id or a uri must be specified.")
if not self.authorize(user, page, schema.Permissions.READ): if not self.authorize(user, page, schema.Permissions.READ):
self.log.error(f"No permission for {user.name} on {page}") self.log.error(f"No permission for {user.name} on {page}")

View File

@ -39,8 +39,11 @@ def bootstrap():
""" """
app.check_state() app.check_state()
# the system user does not get added to the list of Users.
app.db.save(schema.User(name="__system__"))
# create the top-level pages # create the top-level pages
root = app.db.save(schema.Page(name=app.config.VIEW_URI, body=b"This is the home page")) root = app.db.save(schema.Page(name=app.config.VIEW_URI, body=b"This is the home page", uri=""))
users = root.add_member(schema.Page(name="User", body=b"# Users\nusers go here.")) users = root.add_member(schema.Page(name="User", body=b"# Users\nusers go here."))
groups = root.add_member(schema.Page(name="Group", body=b"# Groups\ngroups go here.")) groups = root.add_member(schema.Page(name="Group", body=b"# Groups\ngroups go here."))
@ -53,6 +56,7 @@ def bootstrap():
# create the users # create the users
guest = users.add_member(schema.User(name="guest", body=b"# guest")) guest = users.add_member(schema.User(name="guest", body=b"# guest"))
admin = users.add_member( admin = users.add_member(
schema.User(name=app.config.ADMIN_USERNAME, password="fnord", email=app.config.ADMIN_EMAIL, body=b"# fnord") schema.User(name=app.config.ADMIN_USERNAME, password="fnord", email=app.config.ADMIN_EMAIL, body=b"# fnord")
) )

View File

@ -1,3 +1,4 @@
import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
@ -9,6 +10,9 @@ from ttfrog import schema
READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp] READ_ONLY_FIELD_TYPES = [Collection, Pointer, BackReference, Timestamp]
logger = logging.getLogger(__name__)
@dataclass @dataclass
class Form: class Form:
""" """
@ -26,12 +30,11 @@ class Form:
def prepare(self): def prepare(self):
for key, value in self.data.items(): for key, value in self.data.items():
# filter out fields that cannot be set by the user
if key in self.read_only: if key in self.read_only:
continue continue
if self.record[key] != value:
self.record[key] = value self.record[key] = value
self.record.author = g.user self.record.author = None if self.record == g.user else g.user
return self.record return self.record

View File

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
from textwrap import dedent
from typing import List from typing import List
from grung.types import ( from grung.types import (
@ -19,6 +20,13 @@ from grung.types import (
from tinydb import where from tinydb import where
def app_context():
import ttfrog.app
ttfrog.app.check_state()
return ttfrog.app
class Permissions(StrEnum): class Permissions(StrEnum):
READ = "r" READ = "r"
WRITE = "w" WRITE = "w"
@ -30,6 +38,20 @@ class Page(Record):
A page in the wiki. Just about everything in the databse is either a Page or a subclass of a Page. A page in the wiki. Just about everything in the databse is either a Page or a subclass of a Page.
""" """
default = dedent(
"""
# {name}
*Overview of this page*
## Section 1
*Organize your text into logically separted sections.*
"""
)
@classmethod @classmethod
def fields(cls): def fields(cls):
# fmt: off # fmt: off
@ -37,18 +59,30 @@ class Page(Record):
*super().fields(), *super().fields(),
Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI Field("uri", unique=True), # The URI for the page, relative to the app's VIEW_URI
Field("name"), # The portion of the URI after the last / Field("name"), # The portion of the URI after the last /
TextFilePointer("body", extension='.md'), # The main content blob of the page
Collection("members", Page), # The pages that exist below this page's URI Collection("members", Page), # The pages that exist below this page's URI
BackReference("parent", value_type=Page), # The page that exists above this page's URI
Pointer("author", value_type=User), # The last user to touch the page. Pointer("author", value_type=User), # The last user to touch the page.
DateTime("created"), # When the page was created DateTime("created"), # When the page was created
Timestamp("last_modified"), # The last time the page was modified. Timestamp("last_modified"), # The last time the page was modified.
Dict("acl"), Dict("acl"),
TextFilePointer("body", extension='.md', default=Page.default), # The main content blob of the page
] ]
# fmt: on # fmt: on
def before_insert(self, db): def parent(self):
if self.uri == "":
return None
app = app_context()
parent_uri = ""
if "/" in self.uri:
parent_uri = self.uri.rsplit("/", 1)[0]
for table_name in app.db.tables():
page = app.db.table(table_name).get(where("uri") == parent_uri, recurse=False)
if page:
return page
def before_insert(self, *args, **kwargs):
""" """
Make the following adjustments before saving this record: Make the following adjustments before saving this record:
* Derive the URI from the hierarchy of the parent. * Derive the URI from the hierarchy of the parent.
@ -56,31 +90,25 @@ class Page(Record):
if not self.name: if not self.name:
raise Exception("Must provide a name") raise Exception("Must provide a name")
super().before_insert(db) app = app_context()
super().before_insert(app.db)
if not self.author:
self.author = app.db.User.get(where('name') == '__system__')
now = datetime.utcnow() now = datetime.utcnow()
if not self.doc_id and self.created < now: if not self.doc_id and self.created < now:
self.created = now self.created = now
self.uri = (self.parent.uri + "/" if self.parent and self.parent.uri != "/" else "") + self.name
def after_insert(self, db):
"""
After saving this record, ensure that any page in the members collection is updated with the
correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
"""
super().after_insert(db)
if not hasattr(self, "members"):
return
for child in self.members:
obj = BackReference.dereference(child, db)
obj.uri = f"{self.uri}/{obj.name}"
child = db.save(obj)
def add_member(self, child: Record): def add_member(self, child: Record):
from ttfrog import app app = app_context()
app.check_state() prefix = (self.uri + "/") if self.uri else ""
new_uri = f"{prefix}{child.name}"
if child.uri != new_uri:
app.log.debug(f"Moving {child._metadata.table}[{child.doc_id}] from {child.uri} to {new_uri}")
child.uri = new_uri
self.members = list(set(self.members + [app.db.save(child)])) self.members = list(set(self.members + [app.db.save(child)]))
app.db.save(self) app.db.save(self)
return self.get_child(child) return self.get_child(child)
@ -92,9 +120,7 @@ class Page(Record):
return None return None
def set_permissions(self, entity: Entity, permissions: List) -> str: def set_permissions(self, entity: Entity, permissions: List) -> str:
from ttfrog import app app = app_context()
app.check_state()
perms = "".join(permissions) perms = "".join(permissions)
self.acl[entity.reference] = perms self.acl[entity.reference] = perms
@ -106,9 +132,7 @@ class Page(Record):
Search upward through the page hierarchy looking for one with an ACL that either Search upward through the page hierarchy looking for one with an ACL that either
has a grant for the entity we care about, or at least one group in which the entity is a member. has a grant for the entity we care about, or at least one group in which the entity is a member.
""" """
from ttfrog import app app = app_context()
app.check_state()
def find_acl(obj): def find_acl(obj):
if hasattr(obj, "acl"): if hasattr(obj, "acl"):
@ -126,7 +150,7 @@ class Page(Record):
return group_grants return group_grants
if hasattr(obj, "parent"): if hasattr(obj, "parent"):
return find_acl(obj.parent) return find_acl(obj.parent())
return {"": ""} return {"": ""}
return find_acl(self) return find_acl(self)
@ -171,6 +195,21 @@ class User(Entity):
def check_credentials(self, username: str, password: str) -> bool: def check_credentials(self, username: str, password: str) -> bool:
return username == self.name and self._metadata.fields["password"].compare(password, self.password) return username == self.name and self._metadata.fields["password"].compare(password, self.password)
def after_insert(self, *args, **kwargs):
"""
After saving this record, ensure that any page in the members collection is updated with the
correct URI. This ensures that if a page is moved from one collection to another, the URI is updated.
"""
app = app_context()
super().after_insert(app.db)
if not hasattr(self, "members"):
return
for child in self.members:
obj = BackReference.dereference(child, app.db, recurse=False)
obj.uri = f"{self.uri}/{obj.name}"
child = app.db.save(obj)
class Group(Entity): class Group(Entity):
""" """
@ -186,3 +225,21 @@ class NPC(Page):
""" """
An NPC, editable as a wiki page. An NPC, editable as a wiki page.
""" """
default = dedent(
"""
# {name}
*[Ancestry] [Class]*
| AC | HP | STR | DEX | CON | INT | WIS | CHA
|----|----|-----|-----|-----|-----|-----|------
| 10 | 10 | +0 | +0 | +0 | +0 | +0 | +0
**{name} (they/they)** [description]
* Personality: **[keywords]**
* Flaw: **[flaw]**
* Goal: **[goal]**
"""
)

View File

@ -18,7 +18,7 @@
{% if user.can_write(page) %} {% if user.can_write(page) %}
<script src="{{ url_for('static', filename='editor/toastui-editor-all.min.js' ) }}"></script> <script src="{{ url_for('static', filename='editor/toastui-editor-all.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script> <script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script>
<script>initialize();</script> <script>initialize("{{ app.config.VIEW_URI }}");</script>
{% else %} {% else %}
<script src="{{ url_for('static', filename='viewer/toastui-editor-viewer.min.js' ) }}"></script> <script src="{{ url_for('static', filename='viewer/toastui-editor-viewer.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='viewer/viewer.js' ) }}"></script> <script src="{{ url_for('static', filename='viewer/viewer.js' ) }}"></script>

View File

@ -5,8 +5,26 @@ var contents = null;
var pageContent = null; var pageContent = null;
var saveButton = null; var saveButton = null;
var editorUI = null; var editorUI = null;
var VIEW_URI = null;;
APIv1 = { APIv1 = {
get: function(doc_id, callback) {
(async () => {
const raw = await fetch('/_/v1/get/' + doc_id, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
const res = await raw.json();
if (res['code'] != 200) {
console.error("APIv1 error: ", res)
}
callback(res);
})();
},
put: function(data, callback) { put: function(data, callback) {
(async () => { (async () => {
const raw = await fetch('/_/v1/put/' + window.location.pathname, { const raw = await fetch('/_/v1/put/' + window.location.pathname, {
@ -26,6 +44,26 @@ APIv1 = {
callback(res); callback(res);
})(); })();
}, },
search: function(space, query, callback) {
(async () => {
const raw = await fetch('/_/v1/search/' + space, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
'body': query
}),
});
const res = await raw.json();
if (res['code'] != 200) {
console.error("APIv1 error: ", res)
}
callback(res);
})();
},
}; };
isReadOnly = function() { isReadOnly = function() {
@ -108,8 +146,54 @@ toggleButton = function() {
handleContentChange = function() { handleContentChange = function() {
} }
initialize = function() { autoComplete = function(search_string, matches, callback) {
return new toastui.Editor({ id = `_ac_${search_string}`;
el = document.getElementById(id);
var addEl = false;
if (!el) {
el = document.createElement("ul");
el.id = id;
addEl = true;
}
el.className = 'autocomplete';
el.innerHTML = "";
el.addEventListener("keyup", function(e) {
// do navigation / selection
});
matches.forEach(match => {
var text = match.uri;
for (pos = 0; pos < match.uri.length - search_string.length; pos++) {
var substr = match.name.substring(pos, search_string.length);
if (substr.toLowerCase() == search_string.toLowerCase()) {
text = match.name.substring(0, pos) + "<strong>" + substr + "</strong>" + match.name.substr(pos + substr.length);
break;
}
}
var option = document.createElement("li");
option.innerHTML = text;
option.addEventListener("click", function(e) {
// do selection
});
el.appendChild(option);
});
if (addEl) {
var selection = window.getSelection();
if (selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
range.insertNode(el);
}
}
}
initialize = function(base_uri) {
const macro_rule = /@(\S{3,})/;
VIEW_URI = base_uri;
const ed = new toastui.Editor({
el: editor, el: editor,
initialEditType: 'wysiwyg', initialEditType: 'wysiwyg',
initialValue: "", initialValue: "",
@ -129,6 +213,23 @@ initialize = function() {
{ el: toggleButton(), tooltip: 'Toggle Edit Mode' } { el: toggleButton(), tooltip: 'Toggle Edit Mode' }
], ],
], ],
widgetRules: [ ],
/*
{
rule: macro_rule, toDOM(text) {
const matched = text.match(macro_rule);
const search_string = matched[1];
var replacement = "";
});
console.log(replacement);
return replacement;
},
},
],
*/
events: { events: {
'loadUI': function(e) { 'loadUI': function(e) {
editorUI = e; editorUI = e;
@ -147,4 +248,48 @@ initialize = function() {
'change': handleContentChange, 'change': handleContentChange,
} }
}); });
var searchPos = null;
ed.on('keyup', (editorType, ev) => {
const [start, end] = editorUI.getSelection();
console.log(start, end);
if (ev.key === '@') {
searchPos = start;
console.log(`Setting search position to ${searchPos}`);
return;
}
if (searchPos === null) {
return;
}
var range = window.getSelection().getRangeAt(0);
range.selectNodeContents(editor);
range.setStart(editor, 0);
range.setEnd(editor, end);
var search_string = range.toString();
console.log(search_string);
/*
APIv1.search("", search_string, (res) => {
if (res.code == 404) {
return;
}
const matches = res.response;
if (matches.length == 1) {
replacement = document.createElement('span');
replacement.innerHTML = `<a class="tooltip-preview" data-uri="${matches[0].uri}" href="${VIEW_URI}${matches[0].uri}">${matches[0].name}</a>`;
return;
}
autoComplete(search_string, matches, (selection) => {
console.log(`Selected ${selection}`);
document.remove(options.id);
});
};
*/
});
return ed;
}; };

View File

@ -1,6 +1,8 @@
import json import json
import re
from flask import Response, g, jsonify, redirect, render_template, request, session, url_for from flask import Response, g, jsonify, redirect, render_template, request, session, url_for
from tinydb import where
from ttfrog import app, forms, schema from ttfrog import app, forms, schema
from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError
@ -42,7 +44,7 @@ def rendered(page: schema.Record, template: str = "page.html"):
if not page: if not page:
return Response("Page not found", status=404) return Response("Page not found", status=404)
root = page if page.uri == app.config.VIEW_URI else get_page(app.config.VIEW_URI)[0] root = page if page.uri == "" else get_page("")[0]
return render_template(template, page=page, app=app, breadcrumbs=breadcrumbs(), root=root, user=g.user, g=g) return render_template(template, page=page, app=app, breadcrumbs=breadcrumbs(), root=root, user=g.user, g=g)
@ -62,7 +64,7 @@ def before_request():
g.messages = [] g.messages = []
if not request.path.startswith("/static"): if not request.path.startswith("/static"):
user_id = session.get("user_id", 1) user_id = session.get("user_id", 1)
g.user = app.db.User.get(doc_id=user_id) g.user = app.db.User.get(doc_id=user_id, recurse=False)
session["user_id"] = user_id session["user_id"] = user_id
session["user"] = dict(g.user.serialize()) session["user"] = dict(g.user.serialize())
@ -77,7 +79,7 @@ def add_header(r):
@app.web.route(app.config.VIEW_URI) @app.web.route(app.config.VIEW_URI)
def index(): def index():
page, error = get_page(app.config.VIEW_URI) page, error = get_page("")
if error: if error:
g.messages.append(str(error)) g.messages.append(str(error))
return rendered(page) return rendered(page)
@ -127,12 +129,38 @@ def put(table, path):
params = json.loads(request.data.decode())["body"] params = json.loads(request.data.decode())["body"]
save_data = getattr(forms, table)(page, params).prepare() save_data = getattr(forms, table)(page, params).prepare()
app.log.debug("Saving form data...")
doc = app.db.save(save_data) if page.doc_id else page.parent.add_member(save_data) app.log.debug(f"{save_data=}")
doc = app.db.save(save_data)
app.log.debug(f"Saved {dict(doc)}")
if not page.doc_id:
print(f"Adding {doc.doc_id} to {page.parent.members}")
page.parent.members = list(set(page.parent.members + [doc]))
app.db.save(page.parent)
app.log.debug(f"Saved: {dict(doc)=}") app.log.debug(f"Saved: {dict(doc)=}")
return api_response(response=dict(doc)) return api_response(response=dict(doc))
@app.web.route(f"{app.config.API_URI}/search/<string:space>", methods=["POST"])
@app.web.route(f"{app.config.API_URI}/search/", methods=["POST"], defaults={"space": None})
def search(space):
spaces = app.db.tables()
if space:
spaces = [space.lower().capitalize()]
query = json.loads(request.data.decode())["body"]
app.log.debug(f"Searching for records matching query {query}")
matches = []
for space in spaces:
for page in app.db.table(space).search(where("name").matches(query, re.IGNORECASE), recurse=False):
if app.authorize(g.user, page, schema.Permissions.READ):
app.log.debug(f"Adding search result {dict(page)}")
matches.append(dict(page))
return api_response(
response=matches, error=None if matches else RecordNotFoundError(f"No records matching '{query}'")
)
@app.web.route(f"{app.config.API_URI}/get/<path:table>/<int:doc_id>", methods=["GET"]) @app.web.route(f"{app.config.API_URI}/get/<path:table>/<int:doc_id>", methods=["GET"])
def get(table, doc_id): def get(table, doc_id):
app.log.debug(f"API: getting {table}({doc_id})") app.log.debug(f"API: getting {table}({doc_id})")