diff --git a/tildes/a b/tildes/a new file mode 100644 index 0000000..2802893 --- /dev/null +++ b/tildes/a @@ -0,0 +1,34 @@ + List of relations + Schema | Name | Type | Owner +--------+-----------------------+-------+-------- + public | alembic_version | table | tildes + public | comment_bookmarks | table | tildes + public | comment_labels | table | tildes + public | comment_notifications | table | tildes + public | comment_votes | table | tildes + public | comments | table | tildes + public | financials | table | tildes + public | group_scripts | table | tildes + public | group_stats | table | tildes + public | group_subscriptions | table | tildes + public | group_wiki_pages | table | tildes + public | groups | table | tildes + public | log | table | tildes + public | log_comments | table | tildes + public | log_topics | table | tildes + public | message_conversations | table | tildes + public | message_replies | table | tildes + public | scraper_results | table | tildes + public | topic_bookmarks | table | tildes + public | topic_ignores | table | tildes + public | topic_schedule | table | tildes + public | topic_visits | table | tildes + public | topic_votes | table | tildes + public | topics | table | tildes + public | user_group_settings | table | tildes + public | user_invite_codes | table | tildes + public | user_permissions | table | tildes + public | user_rate_limit | table | tildes + public | users | table | tildes +(29 rows) + diff --git a/tildes/alembic/versions/132ed4b3e988_words_per_minute.py b/tildes/alembic/versions/132ed4b3e988_words_per_minute.py new file mode 100644 index 0000000..0060d1b --- /dev/null +++ b/tildes/alembic/versions/132ed4b3e988_words_per_minute.py @@ -0,0 +1,24 @@ +"""Words per Minute + +Revision ID: 132ed4b3e988 +Revises: 55f4c1f951d5 +Create Date: 2023-06-07 23:44:32.533936 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "132ed4b3e988" +down_revision = "55f4c1f951d5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("users", sa.Column("words_per_minute", sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column("users", "words_per_minute") diff --git a/tildes/consumers/topic_metadata_generator.py b/tildes/consumers/topic_metadata_generator.py index 7ea4842..98220f2 100644 --- a/tildes/consumers/topic_metadata_generator.py +++ b/tildes/consumers/topic_metadata_generator.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Consumer that generates content_metadata for topics.""" - from collections.abc import Sequence from typing import Any from ipaddress import ip_address diff --git a/tildes/development.ini b/tildes/development.ini index bd98b21..f66f08d 100644 --- a/tildes/development.ini +++ b/tildes/development.ini @@ -43,3 +43,4 @@ webassets.base_dir = %(here)s/static webassets.base_url = / webassets.cache = false webassets.manifest = json + diff --git a/tildes/scripts/clean_private_data.py b/tildes/scripts/clean_private_data.py index 452b77e..604d197 100644 --- a/tildes/scripts/clean_private_data.py +++ b/tildes/scripts/clean_private_data.py @@ -207,6 +207,7 @@ class DataCleaner: "last_exemplary_label_time": DEFAULT, "_bio_markdown": DEFAULT, "bio_rendered_html": DEFAULT, + "words_per_minute": DEFAULT, }, synchronize_session=False, ) diff --git a/tildes/select user b/tildes/select user new file mode 100644 index 0000000..a1f433d --- /dev/null +++ b/tildes/select user @@ -0,0 +1,63 @@ +Available help: + ABORT CREATE FOREIGN DATA WRAPPER DROP ROUTINE + ALTER AGGREGATE CREATE FOREIGN TABLE DROP RULE + ALTER COLLATION CREATE FUNCTION DROP SCHEMA + ALTER CONVERSION CREATE GROUP DROP SEQUENCE + ALTER DATABASE CREATE INDEX DROP SERVER + ALTER DEFAULT PRIVILEGES CREATE LANGUAGE DROP STATISTICS + ALTER DOMAIN CREATE MATERIALIZED VIEW DROP SUBSCRIPTION + ALTER EVENT TRIGGER CREATE OPERATOR DROP TABLE + ALTER EXTENSION CREATE OPERATOR CLASS DROP TABLESPACE + ALTER FOREIGN DATA WRAPPER CREATE OPERATOR FAMILY DROP TEXT SEARCH CONFIGURATION + ALTER FOREIGN TABLE CREATE POLICY DROP TEXT SEARCH DICTIONARY + ALTER FUNCTION CREATE PROCEDURE DROP TEXT SEARCH PARSER + ALTER GROUP CREATE PUBLICATION DROP TEXT SEARCH TEMPLATE + ALTER INDEX CREATE ROLE DROP TRANSFORM + ALTER LANGUAGE CREATE RULE DROP TRIGGER + ALTER LARGE OBJECT CREATE SCHEMA DROP TYPE + ALTER MATERIALIZED VIEW CREATE SEQUENCE DROP USER + ALTER OPERATOR CREATE SERVER DROP USER MAPPING + ALTER OPERATOR CLASS CREATE STATISTICS DROP VIEW + ALTER OPERATOR FAMILY CREATE SUBSCRIPTION END + ALTER POLICY CREATE TABLE EXECUTE + ALTER PROCEDURE CREATE TABLE AS EXPLAIN + ALTER PUBLICATION CREATE TABLESPACE FETCH + ALTER ROLE CREATE TEXT SEARCH CONFIGURATION GRANT + ALTER ROUTINE CREATE TEXT SEARCH DICTIONARY IMPORT FOREIGN SCHEMA + ALTER RULE CREATE TEXT SEARCH PARSER INSERT + ALTER SCHEMA CREATE TEXT SEARCH TEMPLATE LISTEN + ALTER SEQUENCE CREATE TRANSFORM LOAD + ALTER SERVER CREATE TRIGGER LOCK + ALTER STATISTICS CREATE TYPE MOVE + ALTER SUBSCRIPTION CREATE USER NOTIFY + ALTER SYSTEM CREATE USER MAPPING PREPARE + ALTER TABLE CREATE VIEW PREPARE TRANSACTION + ALTER TABLESPACE DEALLOCATE REASSIGN OWNED + ALTER TEXT SEARCH CONFIGURATION DECLARE REFRESH MATERIALIZED VIEW + ALTER TEXT SEARCH DICTIONARY DELETE REINDEX + ALTER TEXT SEARCH PARSER DISCARD RELEASE SAVEPOINT + ALTER TEXT SEARCH TEMPLATE DO RESET + ALTER TRIGGER DROP ACCESS METHOD REVOKE + ALTER TYPE DROP AGGREGATE ROLLBACK + ALTER USER DROP CAST ROLLBACK PREPARED + ALTER USER MAPPING DROP COLLATION ROLLBACK TO SAVEPOINT + ALTER VIEW DROP CONVERSION SAVEPOINT + ANALYZE DROP DATABASE SECURITY LABEL + BEGIN DROP DOMAIN SELECT + CALL DROP EVENT TRIGGER SELECT INTO + CHECKPOINT DROP EXTENSION SET + CLOSE DROP FOREIGN DATA WRAPPER SET CONSTRAINTS + CLUSTER DROP FOREIGN TABLE SET ROLE + COMMENT DROP FUNCTION SET SESSION AUTHORIZATION + COMMIT DROP GROUP SET TRANSACTION + COMMIT PREPARED DROP INDEX SHOW + COPY DROP LANGUAGE START TRANSACTION + CREATE ACCESS METHOD DROP MATERIALIZED VIEW TABLE + CREATE AGGREGATE DROP OPERATOR TRUNCATE + CREATE CAST DROP OPERATOR CLASS UNLISTEN + CREATE COLLATION DROP OPERATOR FAMILY UPDATE + CREATE CONVERSION DROP OWNED VACUUM + CREATE DATABASE DROP POLICY VALUES + CREATE DOMAIN DROP PROCEDURE WITH + CREATE EVENT TRIGGER DROP PUBLICATION + CREATE EXTENSION DROP ROLE diff --git a/tildes/static/js/scripts.js b/tildes/static/js/scripts.js index 39ab25f..d4fc7cc 100644 --- a/tildes/static/js/scripts.js +++ b/tildes/static/js/scripts.js @@ -84,9 +84,9 @@ $(function() { } // check if the response came back as HTML (unhandled error of some sort) - if (errorText.lastIndexOf("", 500) !== -1) { - errorText = "Unknown error"; - } + //if (errorText.lastIndexOf("", 500) !== -1) { + // errorText = "Unknown error"; + //} $statusElement.addClass("text-error").text(errorText); } diff --git a/tildes/tildes/enums.py b/tildes/tildes/enums.py index cb5792a..3da5461 100644 --- a/tildes/tildes/enums.py +++ b/tildes/tildes/enums.py @@ -94,6 +94,7 @@ class ContentMetadataFields(enum.Enum): PUBLISHED = enum.auto() TITLE = enum.auto() WORD_COUNT = enum.auto() + TIME_TO_READ = enum.auto() @property def key(self) -> str: @@ -112,10 +113,10 @@ class ContentMetadataFields(enum.Enum): ) -> list["ContentMetadataFields"]: """Return a list of fields to display for detail about a particular type.""" if content_type is TopicContentType.ARTICLE: - return [cls.WORD_COUNT, cls.PUBLISHED] + return [cls.WORD_COUNT, cls.TIME_TO_READ, cls.PUBLISHED] if content_type is TopicContentType.TEXT: - return [cls.WORD_COUNT] + return [cls.WORD_COUNT, cls.TIME_TO_READ] if content_type is TopicContentType.VIDEO: return [cls.DURATION, cls.PUBLISHED] @@ -155,6 +156,9 @@ class ContentMetadataFields(enum.Enum): return f"{word_count} words" + if self.name == "TIME_TO_READ": + return f"{value:.1f} minutes" + return str(value) diff --git a/tildes/tildes/models/user/user.py b/tildes/tildes/models/user/user.py index 2229824..c6ddd3a 100644 --- a/tildes/tildes/models/user/user.py +++ b/tildes/tildes/models/user/user.py @@ -135,6 +135,9 @@ class User(DatabaseModel): ) comment_label_weight: Optional[float] = Column(REAL) last_exemplary_label_time: Optional[datetime] = Column(TIMESTAMP(timezone=True)) + + words_per_minute: int = Column(Integer, default=0) + _bio_markdown: str = deferred( Column( "bio_markdown", diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index e306130..a1d3e5a 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -105,6 +105,9 @@ def includeme(config: Configurator) -> None: config.add_route( "settings_theme_previews", "/theme_previews", factory=LoggedInFactory ) + config.add_route("settings_wpm", "/wpm", factory=LoggedInFactory) + # config.add_route("user_wpm", "/user/{username}/settings/wpm", factory=user_by_username) + # config.add_route("patch_change_wpm", "/settings/wpm") config.add_route("bookmarks", "/bookmarks", factory=LoggedInFactory) config.add_route("ignored_topics", "/ignored_topics", factory=LoggedInFactory) @@ -183,6 +186,8 @@ def add_intercooler_routes(config: Configurator) -> None: add_ic_route("markdown_preview", "/markdown_preview", factory=LoggedInFactory) + # add_ic_route("user_wpm", "/settings/wpm", factory=user_by_username) + class LoggedInFactory: """Simple class to use as `factory` to restrict routes to logged-in users. diff --git a/tildes/tildes/schemas/user.py b/tildes/tildes/schemas/user.py index 44bf55f..e587f63 100644 --- a/tildes/tildes/schemas/user.py +++ b/tildes/tildes/schemas/user.py @@ -8,8 +8,8 @@ from typing import Any from marshmallow import post_dump, pre_load, Schema, validates, validates_schema from marshmallow.exceptions import ValidationError -from marshmallow.fields import DateTime, Email, String -from marshmallow.validate import Length, Regexp +from marshmallow.fields import DateTime, Email, String, Integer +from marshmallow.validate import Length, Regexp, Range from tildes.lib.password import is_breached_password from tildes.schemas.fields import Markdown @@ -58,6 +58,7 @@ class UserSchema(Schema): email_address_note = String(validate=Length(max=EMAIL_ADDRESS_NOTE_MAX_LENGTH)) created_time = DateTime(dump_only=True) bio_markdown = Markdown(max_length=BIO_MAX_LENGTH, allow_none=True) + words_per_minute = Integer(validate=Range(min=0, max=9999)) @post_dump def anonymize_username(self, data: dict, many: bool) -> dict: diff --git a/tildes/tildes/templates/settings.jinja2 b/tildes/tildes/templates/settings.jinja2 index 064762d..0b0685c 100644 --- a/tildes/tildes/templates/settings.jinja2 +++ b/tildes/tildes/templates/settings.jinja2 @@ -260,6 +260,10 @@ Edit your user bio
Tell others about yourself with a short bio on your user page
+
  • + Edit your reading speed +
    Customize your reading speed for personalized reading time estimates
    +
  • Set up account recovery
    To be able to regain access in case of lost password, compromise, etc.
    diff --git a/tildes/tildes/templates/settings_wpm.jinja2 b/tildes/tildes/templates/settings_wpm.jinja2 new file mode 100644 index 0000000..0d3b38f --- /dev/null +++ b/tildes/tildes/templates/settings_wpm.jinja2 @@ -0,0 +1,32 @@ +{# Copyright (c) 2023 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_settings.jinja2' %} + +{% block title %}Edit your reading speed{% endblock %} + +{% block main_heading %}Edit your reading speed{% endblock %} + +{% block settings %} +

    Enter your estimated reading speed in words per minute (WPM). This will be used to calculate estimated reading times for posts. To disable reading time from displaying on Link topics, enter a value of 0.

    + +
    + +
    +
    + + +
    + +
    + +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/topic.jinja2 b/tildes/tildes/templates/topic.jinja2 index bfbd101..0a19b46 100644 --- a/tildes/tildes/templates/topic.jinja2 +++ b/tildes/tildes/templates/topic.jinja2 @@ -93,7 +93,7 @@ {% endif %} {% if content_metadata %} - {{ topic_content_metadata(content_metadata) }} + {{ topic_content_metadata(content_metadata, words_per_minute) }} {% endif %} {% endif %} {% endif %} @@ -364,15 +364,38 @@ {% endblock %} -{% macro topic_content_metadata(content_metadata) %} +{% macro topic_content_metadata(content_metadata, words_per_minute) %} {% endmacro %} + + + + + diff --git a/tildes/tildes/views/api/web/exceptions.py b/tildes/tildes/views/api/web/exceptions.py index 827ba61..8f3c0fa 100644 --- a/tildes/tildes/views/api/web/exceptions.py +++ b/tildes/tildes/views/api/web/exceptions.py @@ -75,8 +75,8 @@ def error_to_text_response(request: Request) -> Response: elif isinstance(request.exception, HTTPBadRequest): if response.title == "Bad CSRF Token": response.text = "Page expired, reload and try again" - else: - response.text = "Unknown error" + # else: + # response.text = "Unknown error" return response diff --git a/tildes/tildes/views/api/web/user.py b/tildes/tildes/views/api/web/user.py index ef80973..af473bf 100644 --- a/tildes/tildes/views/api/web/user.py +++ b/tildes/tildes/views/api/web/user.py @@ -9,6 +9,7 @@ from typing import Optional from marshmallow import ValidationError from marshmallow.fields import String +from marshmallow.fields import Int from pyramid.httpexceptions import ( HTTPForbidden, HTTPUnauthorized, @@ -275,15 +276,15 @@ def patch_change_collapse_old_comments(request: Request) -> Response: @ic_view_config( route_name="user", request_method="PATCH", - request_param="ic-trigger-name=account-default-theme", + request_param="ic-trigger-name=user-bio", permission="change_settings", ) -def patch_change_account_default_theme(request: Request) -> Response: - """Change the user's "theme account default" setting.""" +@use_kwargs({"markdown": String()}, location="form") +def patch_change_user_bio(request: Request, markdown: str) -> dict: + """Update a user's bio.""" user = request.context - new_theme = request.params.get("theme") - user.theme_default = new_theme + user.bio_markdown = markdown return IC_NOOP @@ -291,15 +292,15 @@ def patch_change_account_default_theme(request: Request) -> Response: @ic_view_config( route_name="user", request_method="PATCH", - request_param="ic-trigger-name=user-bio", + request_param="ic-trigger-name=wpm-change", permission="change_settings", ) -@use_kwargs({"markdown": String()}, location="form") -def patch_change_user_bio(request: Request, markdown: str) -> dict: - """Update a user's bio.""" +@use_kwargs({"words_per_minute": Int()}, location="form") +def patch_change_wpm(request: Request, words_per_minute: int) -> dict: + """Change the user's words per minute (WPM) setting.""" user = request.context - user.bio_markdown = markdown + user.words_per_minute = words_per_minute return IC_NOOP diff --git a/tildes/tildes/views/settings.py b/tildes/tildes/views/settings.py index d6c9b38..722dda1 100644 --- a/tildes/tildes/views/settings.py +++ b/tildes/tildes/views/settings.py @@ -257,3 +257,9 @@ def get_settings_theme_previews(request: Request) -> dict: "fake_comment_tree": fake_tree, "last_visit": fake_last_visit_time, } + + +@view_config(route_name="settings_wpm", renderer="settings_wpm.jinja2") +def get_settings_wpm(request: Request) -> dict: + """Render the WPM settings page.""" + return {} diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py index 0d59776..b0fb0fd 100644 --- a/tildes/tildes/views/topic.py +++ b/tildes/tildes/views/topic.py @@ -464,7 +464,7 @@ def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: ) tree.collapse_from_labels() - + user_wpm = 0 # Initialize user_wpm with a default value of 0 if request.user: request.db_session.add(TopicVisit(request.user, topic)) @@ -474,6 +474,9 @@ def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: tree.uncollapse_new_comments(topic.last_visit_time) tree.finalize_collapsing_maximized() + # Get user's wpm setting or use a default if not set + user_wpm = request.user.words_per_minute or 0 + return { "topic": topic, "content_metadata": content_metadata, @@ -482,6 +485,7 @@ def get_topic(request: Request, comment_order: CommentTreeSortOption) -> dict: "comment_order": comment_order, "comment_order_options": CommentTreeSortOption, "comment_label_options": CommentLabelOption, + "words_per_minute": user_wpm, }