Put root page on uri "", make parent() a method

This commit is contained in:
evilchili 2025-10-29 19:06:57 -07:00
parent 6afab2a15c
commit 5fffbf59f5
7 changed files with 268 additions and 52 deletions

View File

@ -153,45 +153,42 @@ 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 = '/' search_uri = uri
page_name = '/' page_name = uri
if "/" in uri: if "/" in uri:
(parent_uri, page_name) = uri.rsplit("/", 1) (parent_uri, page_name) = uri.rsplit("/", 1)
if parent_uri == 'Page': if parent_uri == 'Page':
parent_uri = '/' parent_uri = None
search_uri = page_name search_uri = page_name
else: else:
search_uri = uri search_uri = uri
elif uri:
parent_uri = "/"
search_uri = uri
page_name = uri
self.log.debug(f"Searching for page in {table = } with {search_uri = }; its parent is {parent_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 +213,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

@ -40,7 +40,7 @@ def bootstrap():
app.check_state() app.check_state()
# 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."))

View File

@ -1,5 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
import logging
from flask import g from flask import g
from grung.types import BackReference, Collection, Pointer, Record, Timestamp from grung.types import BackReference, Collection, Pointer, Record, Timestamp
@ -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:
""" """
@ -29,8 +33,9 @@ class Form:
# filter out fields that cannot be set by the user # 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 logger.debug(f"Updating {self.record.__class__.__name__}[{self.record.doc_id}] {key}={value}")
self.record[key] = value
self.record.author = g.user self.record.author = g.user
return self.record return self.record

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import List from typing import List
from textwrap import dedent
from grung.types import ( from grung.types import (
BackReference, BackReference,
@ -29,6 +30,17 @@ 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):
@ -37,16 +49,27 @@ 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 parent(self, db):
if self.uri == "":
return None
parent_uri = ""
if "/" in self.uri:
parent_uri = self.uri.rsplit("/", 1)[0]
print(f"Checking for parent at {parent_uri=}")
for table_name in db.tables():
page = db.table(table_name).get(where('uri') == parent_uri, recurse=False)
if page:
return page
def before_insert(self, db): def before_insert(self, db):
""" """
@ -62,25 +85,15 @@ class Page(Record):
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 from ttfrog import app
app.check_state() 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)
@ -126,7 +139,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(app.db))
return {"": ""} return {"": ""}
return find_acl(self) return find_acl(self)
@ -171,6 +184,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, 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)
for name, _field in self._metadata.fields.items():
_field.after_insert(db, self)
if not hasattr(self, "members"):
return
for child in self.members:
obj = BackReference.dereference(child, db, recurse=False)
obj.uri = f"{self.uri}/{obj.name}"
child = db.save(obj)
class Group(Entity): class Group(Entity):
""" """
@ -186,3 +214,18 @@ 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

@ -4,6 +4,8 @@ from flask import Response, g, jsonify, redirect, render_template, request, sess
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
from tinydb import where
import re
def get_page( def get_page(
@ -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,39 @@ 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) 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})")