diff --git a/src/ttfrog/app.py b/src/ttfrog/app.py index 0b7fbaf..47d2fda 100644 --- a/src/ttfrog/app.py +++ b/src/ttfrog/app.py @@ -153,45 +153,42 @@ API_URI=/_/v1/ if uri.startswith(self.config.VIEW_URI): uri = uri.replace(self.config.VIEW_URI, "", 1) - parent_uri = '' - search_uri = '/' - page_name = '/' + parent_uri = None + search_uri = uri + page_name = uri if "/" in uri: (parent_uri, page_name) = uri.rsplit("/", 1) if parent_uri == 'Page': - parent_uri = '/' + parent_uri = None search_uri = page_name else: 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()])) page = table.get(where("uri") == search_uri, recurse=False) if not page: # 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=}") - parent_table = table if "/" in parent_uri else self.db.Page + # self.log.debug(f"Page at {search_uri} does not exist, looking for parent at {parent_uri=}") + parent_table = table if parent_uri and "/" in parent_uri else self.db.Page parent = None 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) except Exception as e: self.log.debug(f"Error loading parent: {e}") 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): 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 - ) - self.log.debug(f"Returning {page.doc_id}: {page.uri}") + + 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}") return page 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) if not page: raise RecordNotFoundError(f"No record with {doc_id=} was found.") - elif 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.") + + page = self._get_or_create_page_by_uri(user, table, uri) if not self.authorize(user, page, schema.Permissions.READ): self.log.error(f"No permission for {user.name} on {page}") diff --git a/src/ttfrog/bootstrap.py b/src/ttfrog/bootstrap.py index eb6631a..731cc88 100644 --- a/src/ttfrog/bootstrap.py +++ b/src/ttfrog/bootstrap.py @@ -40,7 +40,7 @@ def bootstrap(): app.check_state() # 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.")) groups = root.add_member(schema.Page(name="Group", body=b"# Groups\ngroups go here.")) diff --git a/src/ttfrog/forms.py b/src/ttfrog/forms.py index 62e492a..c925eea 100644 --- a/src/ttfrog/forms.py +++ b/src/ttfrog/forms.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from functools import cached_property +import logging from flask import g 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] +logger = logging.getLogger(__name__) + + @dataclass class Form: """ @@ -29,8 +33,9 @@ class Form: # filter out fields that cannot be set by the user if key in self.read_only: continue - - self.record[key] = value + if 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 return self.record diff --git a/src/ttfrog/schema.py b/src/ttfrog/schema.py index 969a68b..93b89f1 100644 --- a/src/ttfrog/schema.py +++ b/src/ttfrog/schema.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from enum import StrEnum from typing import List +from textwrap import dedent from grung.types import ( 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. """ + default = dedent(""" +# {name} + +*Overview of this page* + + +## Section 1 + +*Organize your text into logically separted sections.* + + """) @classmethod def fields(cls): @@ -37,16 +49,27 @@ class Page(Record): *super().fields(), 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 / - TextFilePointer("body", extension='.md'), # The main content blob of the page 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. DateTime("created"), # When the page was created Timestamp("last_modified"), # The last time the page was modified. 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): """ @@ -62,25 +85,15 @@ class Page(Record): if not self.doc_id and 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): from ttfrog import app 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)])) app.db.save(self) return self.get_child(child) @@ -126,7 +139,7 @@ class Page(Record): return group_grants if hasattr(obj, "parent"): - return find_acl(obj.parent) + return find_acl(obj.parent(app.db)) return {"": ""} return find_acl(self) @@ -171,6 +184,21 @@ class User(Entity): def check_credentials(self, username: str, password: str) -> bool: 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): """ @@ -186,3 +214,18 @@ class NPC(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]** + +""") diff --git a/src/ttfrog/themes/default/page.html b/src/ttfrog/themes/default/page.html index 118b296..db4dae9 100644 --- a/src/ttfrog/themes/default/page.html +++ b/src/ttfrog/themes/default/page.html @@ -18,7 +18,7 @@ {% if user.can_write(page) %} - + {% else %} diff --git a/src/ttfrog/themes/default/static/editor/editor.js b/src/ttfrog/themes/default/static/editor/editor.js index 21b3d1f..62a3516 100644 --- a/src/ttfrog/themes/default/static/editor/editor.js +++ b/src/ttfrog/themes/default/static/editor/editor.js @@ -5,8 +5,26 @@ var contents = null; var pageContent = null; var saveButton = null; var editorUI = null; +var VIEW_URI = null;; 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) { (async () => { const raw = await fetch('/_/v1/put/' + window.location.pathname, { @@ -26,6 +44,26 @@ APIv1 = { 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() { @@ -108,8 +146,54 @@ toggleButton = function() { handleContentChange = function() { } -initialize = function() { - return new toastui.Editor({ +autoComplete = function(search_string, matches, callback) { + 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) + "" + substr + "" + 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, initialEditType: 'wysiwyg', initialValue: "", @@ -129,6 +213,23 @@ initialize = function() { { 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: { 'loadUI': function(e) { editorUI = e; @@ -147,4 +248,48 @@ initialize = function() { '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 = `${matches[0].name}`; + return; + } + + autoComplete(search_string, matches, (selection) => { + console.log(`Selected ${selection}`); + document.remove(options.id); + }); + }; + */ + }); + + return ed; }; diff --git a/src/ttfrog/web.py b/src/ttfrog/web.py index 9f3a32c..e01592a 100644 --- a/src/ttfrog/web.py +++ b/src/ttfrog/web.py @@ -4,6 +4,8 @@ from flask import Response, g, jsonify, redirect, render_template, request, sess from ttfrog import app, forms, schema from ttfrog.exceptions import MalformedRequestError, RecordNotFoundError, UnauthorizedError +from tinydb import where +import re def get_page( @@ -42,7 +44,7 @@ def rendered(page: schema.Record, template: str = "page.html"): if not page: 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) @@ -62,7 +64,7 @@ def before_request(): g.messages = [] if not request.path.startswith("/static"): 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"] = dict(g.user.serialize()) @@ -77,7 +79,7 @@ def add_header(r): @app.web.route(app.config.VIEW_URI) def index(): - page, error = get_page(app.config.VIEW_URI) + page, error = get_page("") if error: g.messages.append(str(error)) return rendered(page) @@ -127,12 +129,39 @@ def put(table, path): params = json.loads(request.data.decode())["body"] save_data = getattr(forms, table)(page, params).prepare() - - doc = app.db.save(save_data) if page.doc_id else page.parent.add_member(save_data) + app.log.debug("Saving form 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)=}") return api_response(response=dict(doc)) +@app.web.route(f"{app.config.API_URI}/search/", 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//", methods=["GET"]) def get(table, doc_id): app.log.debug(f"API: getting {table}({doc_id})")