Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

23 changed files with 334 additions and 664 deletions

View file

@ -1,12 +1,5 @@
# Whats New
## 1.1.0
+ **Deprecated** several configuration values ~
+ **Schema changes**: several columns changed format. Update your schema.
+ Removed permanently the remains of extensions.
+ I18n improvements.
## 1.0.0
+ **BREAKING CHANGES AHEAD**!
@ -18,7 +11,7 @@
+ Switched to `pyproject.toml`. `requirements.txt` has been sunset.
+ Switched to the Apache License; the old license text is moved to `LICENSE.0_9`
+ Added color themes! This is a breaking (but trivial) aesthetic change. Default theme is 'Miku' (aquamarine green).
+ Extensions **have been removed**. They never had a clear, usable, public API in the first place.
+ Extensions have been removed. They never had a clear, usable, public API in the first place.
## 0.9

View file

@ -1 +0,0 @@
Generic single-database configuration.

View file

@ -1,79 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from salvi.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -1,86 +0,0 @@
"""empty message
Sorry, due to unattended changes, upgrade from 1.0.0 is not reliable and untested. Sorry.
Revision ID: ae0587e14725
Revises:
Create Date: 2025-10-04 09:43:41.158057
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = 'ae0587e14725'
down_revision: Union[str, None] = 'ebde30d24167'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
#op.drop_index('pagepolicykey_passphrase_sec_code', table_name='pagepolicykey')
#op.drop_index('page_owner', table_name='page')
op.create_index(op.f('ix_page_calendar'), 'page', ['calendar'], unique=False)
op.create_index('page_calendar', 'page', ['calendar'], unique=False)
op.drop_index('user_id', table_name='usergroupmembership')
op.drop_index('user_id_2', table_name='usergroupmembership')
#op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
#op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
op.create_index('user_id_2', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
op.create_index('user_id', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
op.drop_index('page_calendar', table_name='page')
op.drop_index(op.f('ix_page_calendar'), table_name='page')
op.create_index('page_owner', 'page', ['owner_id'], unique=False)
op.create_table('upload',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('upload_date', mysql.DATETIME(), nullable=False),
sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
op.create_index('upload_md5', 'upload', ['md5'], unique=False)
op.create_table('pagepolicy',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
op.create_table('pagepolicykey',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicykey_passphrase_sec_code', 'pagepolicykey', ['passphrase', 'sec_code'], unique=True)
# ### end Alembic commands ###

View file

@ -1,312 +0,0 @@
"""empty message
Revision ID: ebde30d24167
Revises: ae0587e14725
Create Date: 2025-10-10 19:01:04.309127
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = 'ebde30d24167'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('pagepolicy',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
op.create_table('pagepolicykey',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.alter_column('page', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=True)
op.alter_column('page', 'touched',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('page', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.drop_index('page_calendar', table_name='page')
op.drop_index('page_owner', table_name='page')
op.drop_index('page_title', table_name='page')
op.drop_index('page_touched', table_name='page')
op.create_index(op.f('ix_page_title'), 'page', ['title'], unique=False)
op.create_index(op.f('ix_page_touched'), 'page', ['touched'], unique=False)
op.alter_column('pagelink', 'from_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagelink', 'to_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_constraint('pagelink_ibfk_2', 'pagelink', type_='foreignkey')
op.drop_constraint('pagelink_ibfk_1', 'pagelink', type_='foreignkey')
op.create_foreign_key(None, 'pagelink', 'page', ['from_page_id'], ['id'])
op.create_foreign_key(None, 'pagelink', 'page', ['to_page_id'], ['id'])
op.alter_column('pagepermission', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagepermission', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagepermission', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.drop_constraint('pagepermission_ibfk_4', 'pagepermission', type_='foreignkey')
op.drop_constraint('pagepermission_ibfk_5', 'pagepermission', type_='foreignkey')
op.create_foreign_key(None, 'pagepermission', 'usergroup', ['group_id'], ['id'])
op.create_foreign_key(None, 'pagepermission', 'page', ['page_id'], ['id'])
op.alter_column('pageproperty', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pageproperty', 'key',
existing_type=mysql.VARCHAR(length=64),
nullable=True)
op.alter_column('pageproperty', 'value',
existing_type=mysql.VARCHAR(length=8000),
nullable=True)
op.alter_column('pagerevision', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagerevision', 'comment',
existing_type=mysql.VARCHAR(length=1024),
nullable=True)
op.alter_column('pagerevision', 'textref_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagerevision', 'pub_date',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('pagerevision', 'length',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_constraint('pagerevision_ibfk_1', 'pagerevision', type_='foreignkey')
op.drop_constraint('pagerevision_ibfk_2', 'pagerevision', type_='foreignkey')
op.drop_constraint('pagerevision_ibfk_3', 'pagerevision', type_='foreignkey')
op.create_foreign_key(None, 'pagerevision', 'pagetext', ['textref_id'], ['id'])
op.create_foreign_key(None, 'pagerevision', 'page', ['page_id'], ['id'])
op.create_foreign_key(None, 'pagerevision', 'user', ['user_id'], ['id'])
op.alter_column('pagetag', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('pagetag', 'name',
existing_type=mysql.VARCHAR(length=64),
nullable=True)
op.drop_constraint('pagetag_ibfk_1', 'pagetag', type_='foreignkey')
op.create_foreign_key(None, 'pagetag', 'page', ['page_id'], ['id'])
op.alter_column('pagetext', 'content',
existing_type=sa.BLOB(),
nullable=True)
op.alter_column('pagetext', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.add_column('user', sa.Column('privileges', sa.BigInteger(), nullable=True))
op.add_column('user', sa.Column('restrictions', sa.BigInteger(), nullable=True))
op.alter_column('user', 'username',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.alter_column('user', 'password',
existing_type=mysql.VARCHAR(length=255),
nullable=True)
op.alter_column('user', 'join_date',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('user', 'karma',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_column('user', 'is_disabled')
op.drop_column('user', 'is_admin')
op.drop_column('user', 'color_theme')
op.alter_column('usergroup', 'name',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.alter_column('usergroup', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=True)
op.alter_column('usergroupmembership', 'user_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('usergroupmembership', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('usergroupmembership', 'since',
existing_type=mysql.DATETIME(),
nullable=True)
op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
# ### end Alembic commands ###
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
op.alter_column('usergroupmembership', 'since',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('usergroupmembership', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('usergroupmembership', 'user_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('usergroup', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('usergroup', 'name',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
op.add_column('user', sa.Column('color_theme', mysql.SMALLINT(display_width=6), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.add_column('user', sa.Column('is_admin', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.add_column('user', sa.Column('is_disabled', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.alter_column('user', 'karma',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('user', 'join_date',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('user', 'password',
existing_type=mysql.VARCHAR(length=255),
nullable=False)
op.alter_column('user', 'username',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
op.drop_column('user', 'restrictions')
op.drop_column('user', 'privileges')
op.alter_column('pagetext', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('pagetext', 'content',
existing_type=sa.BLOB(),
nullable=False)
op.drop_constraint(None, 'pagetag', type_='foreignkey')
op.create_foreign_key('pagetag_ibfk_1', 'pagetag', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagetag', 'name',
existing_type=mysql.VARCHAR(length=64),
nullable=False)
op.alter_column('pagetag', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.drop_constraint(None, 'pagerevision', type_='foreignkey')
op.create_foreign_key('pagerevision_ibfk_3', 'pagerevision', 'user', ['user_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key('pagerevision_ibfk_2', 'pagerevision', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagerevision_ibfk_1', 'pagerevision', 'pagetext', ['textref_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagerevision', 'length',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagerevision', 'pub_date',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('pagerevision', 'textref_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagerevision', 'comment',
existing_type=mysql.VARCHAR(length=1024),
nullable=False)
op.alter_column('pagerevision', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pageproperty', 'value',
existing_type=mysql.VARCHAR(length=8000),
nullable=False)
op.alter_column('pageproperty', 'key',
existing_type=mysql.VARCHAR(length=64),
nullable=False)
op.alter_column('pageproperty', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagepermission', type_='foreignkey')
op.drop_constraint(None, 'pagepermission', type_='foreignkey')
op.create_foreign_key('pagepermission_ibfk_5', 'pagepermission', 'usergroup', ['group_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagepermission_ibfk_4', 'pagepermission', 'page', ['page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagepermission', 'permissions',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('pagepermission', 'group_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagepermission', 'page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_constraint(None, 'pagelink', type_='foreignkey')
op.drop_constraint(None, 'pagelink', type_='foreignkey')
op.create_foreign_key('pagelink_ibfk_1', 'pagelink', 'page', ['to_page_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('pagelink_ibfk_2', 'pagelink', 'page', ['from_page_id'], ['id'], ondelete='CASCADE')
op.alter_column('pagelink', 'to_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('pagelink', 'from_page_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_index(op.f('ix_page_touched'), table_name='page')
op.drop_index(op.f('ix_page_title'), table_name='page')
op.create_index('page_touched', 'page', ['touched'], unique=False)
op.create_index('page_title', 'page', ['title'], unique=False)
op.create_index('page_owner', 'page', ['owner_id'], unique=False)
op.create_index('page_calendar', 'page', ['calendar'], unique=False)
op.alter_column('page', 'flags',
existing_type=mysql.BIGINT(display_width=20),
nullable=False)
op.alter_column('page', 'touched',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('page', 'title',
existing_type=mysql.VARCHAR(length=256),
nullable=False)
op.create_table('upload',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('upload_date', mysql.DATETIME(), nullable=False),
sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
mariadb_collate='utf8mb4_general_ci',
mariadb_default_charset='utf8mb4',
mariadb_engine='InnoDB'
)
op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
op.create_index('upload_md5', 'upload', ['md5'], unique=False)
try:
## drop tables removed in 0.x
op.drop_table('pagepolicykey')
op.drop_index('pagepolicy_key_id', table_name='pagepolicy')
op.drop_index('pagepolicy_page_id', table_name='pagepolicy')
op.drop_index('pagepolicy_page_id_key_id', table_name='pagepolicy')
op.drop_table('pagepolicy')
op.drop_index('upload_md5', table_name='upload')
op.drop_index('upload_upload_date', table_name='upload')
op.drop_table('upload')
except Exception:
pass
# ### end Alembic commands ###

View file

@ -9,7 +9,6 @@
"back-to": "Back to",
"backlinks": "Backlinks",
"backlinks-empty": "No other pages linking here. Is this page orphan?",
"bad-request": "Bad request",
"calculate": "Calculate",
"calendar": "Calendar",
"confirm-password": "Confirm password",
@ -19,8 +18,6 @@
"groups-count": "User group count",
"have-read-terms": "I have read {0} and {1}",
"homepage": "Homepage",
"in-the-future": "in the future",
"in-the-past": "in the past",
"include-tags": "Include tags",
"input-tags": "Tags (comma separated)",
"jump-to-actions": "Jump to actions",
@ -30,7 +27,6 @@
"latest-uploads": "Latest uploads",
"logged-in-as": "Logged in as",
"login": "Log in",
"manage-accounts": "Manage accounts",
"month": "Month",
"n-days-ago": "{0} days ago",
"n-hours-ago": "{0} hours ago",
@ -44,7 +40,6 @@
"not-found-text-2": "does not exist.",
"not-logged-in": "Not logged in",
"note-history": "Page history",
"notes-by-date": "Pages by date",
"notes-count": "Number of pages",
"notes-count-with-url": "Number of pages with URL set",
"notes-month-empty": "None found :(",
@ -67,13 +62,10 @@
"search-results": "Search results for",
"search-no-results": "No results for",
"show-all": "Show all",
"show-more-years": "Show more years",
"sign-up": "Sign up",
"tags": "Tags",
"terms-of-service": "Terms of Service",
"upload-file": "Upload file",
"user-generated-warning": "Any content in this site is user-generated and",
"user-liability-warning": "each user is responsible for what they publish",
"username": "Username",
"users-count": "Number of users",
"welcome": "Welcome to {0}!",

View file

@ -9,7 +9,6 @@
"back-to": "Torna a",
"backlinks": "Collegamenti in entrata",
"backlinks-empty": "Nessuna altra pagina punta qui. Questa pagina è orfana?",
"bad-request": "Richiesta non conforme",
"calculate": "Calcola",
"calendar": "Calendario",
"confirm-password": "Conferma password",

View file

@ -15,7 +15,7 @@ dependencies = [
"Flask-WTF",
"python-dotenv>=1.0.0",
"pymysql",
"sakuragasaki46-suou>=0.7.0"
"sakuragasaki46-suou>=0.6.0"
]
requires-python = ">=3.10"
classifiers = [

View file

@ -8,21 +8,27 @@ Pages are stored in SQLite/MySQL databases.
Markdown is used for text formatting.
'''
__version__ = '1.1.0-dev40'
__version__ = '1.0.0'
from flask import (
Flask, g, make_response, redirect,
Flask, abort, flash, g, jsonify, make_response, redirect,
request, render_template, send_from_directory
)
from markupsafe import Markup
from flask_login import LoginManager
from flask_login import LoginManager, login_user, logout_user, current_user, login_required
from flask_wtf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import select
from suou.configparse import ConfigOptions, ConfigParserConfigSource, ConfigValue
from suou.flask import add_context_from_config
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.routing import BaseConverter
import html, os
from functools import partial
import datetime, hashlib, html, importlib, json, markdown, os, random, \
re, sys, warnings
from functools import lru_cache, partial
from urllib.parse import quote
import gzip
from getpass import getpass
from configparser import ConfigParser
import dotenv
@ -34,13 +40,11 @@ dotenv.load_dotenv(os.path.join(APP_BASE_DIR, '.env'))
class SiteConfig(ConfigOptions):
app_name = ConfigValue(default='Salvi', legacy_src='site.title', public=True)
## v--- will change to server_name in 2.0.0
domain_name = ConfigValue(public=True)
database_url = ConfigValue(legacy_src='database.url', required=True)
secret_key = ConfigValue(required=True)
default_group = ConfigValue(prefix='salvi_', cast=int, default=1)
material_icons_url = ConfigValue(default='https://fonts.googleapis.com/icon?family=Material+Icons', public=True)
# v--- pending deprecation
default_items_per_page = ConfigValue(prefix='salvi_', legacy_src='appearance.items_per_page', default=20, cast=int)
...

View file

@ -9,7 +9,7 @@ PING_RE = r'(?<!\w)@(' + USERNAME_RE + r')'
FORBIDDEN_URLS = [
'about', 'accounts', 'ajax', 'backlinks', 'calendar', 'circles', 'create',
'easter', 'edit', 'embed', 'group', 'help', 'history', 'init-config',
'easter', 'edit', 'embed', 'group', 'help', 'history', 'init-config', 'kt',
'manage', 'media', 'p', 'privacy', 'protect', 'rules', 'search', 'static',
'stats', 'tags', 'terms', 'theme-switch', 'timeline', 'u', 'upload', 'upload-info', 'v1'
'stats', 'tags', 'terms', 'theme-switch', 'u', 'upload', 'upload-info', 'v1'
]

View file

@ -6,20 +6,22 @@ from __future__ import annotations
from functools import partial, wraps
from getpass import getpass
import os
import re
import sys
from typing import Any, Callable, Iterable, List, Mapping
import datetime
import gzip
import warnings
# sqlalchemy imports
from flask import abort, flash
from flask_login import AnonymousUserMixin, current_user, login_required
from flask_sqlalchemy import SQLAlchemy
from markupsafe import Markup
# sqlalchemy imports
from sqlalchemy.orm import Mapped, Relationship, declarative_base, mapped_column, relationship
from sqlalchemy import Index, Integer, SmallInteger, String, DateTime, BigInteger, ForeignKey, UniqueConstraint, BLOB as Blob, delete, func, insert, or_, select, text, update
from sqlalchemy.orm import Relationship, declarative_base, deferred, relationship
from sqlalchemy import Column, Integer, String, DateTime, BigInteger, ForeignKey, UniqueConstraint, create_engine, Index, BLOB as Blob, delete, func, insert, or_, select, update
import datetime
import os
import gzip
from suou.functools import deprecated, deprecated_alias, not_implemented
from werkzeug.security import generate_password_hash
@ -31,8 +33,59 @@ from .i18n import human_elapsed_time
current_user: User
## TODO consider moving it to suou.
## TODO consider sqlalchemy.ext.hybrid.hybrid_property works
from sqlalchemy.ext.hybrid import Comparator
class _BitComparator(Comparator):
_column: Column
_flag: int
def __init__(self, col, flag):
self._column = col
self._flag = flag
def _bulk_update_tuples(self, value):
return [ (self._column, self._upd_exp(value)) ]
def operate(self, op, other, **kwargs):
return op(self._sel_exp(), self._flag if other else 0, **kwargs)
def __clause_element__(self):
return self._column
def __str__(self):
return self._column
def _sel_exp(self):
return self._column.op('&')(self._flag)
def _upd_exp(self, value):
return self._column.op('|')(self._flag) if value else self._column.op('&')(~self._flag)
class BitSelector:
_column: Column
_flag: int
_name: str
def __init__(self, column, flag: int):
if bin(flag := int(flag))[2:].rstrip('0') != '1':
warnings.warn('using non-powers of 2 as flags may cause errors or undefined behavior', FutureWarning)
self._column = column
self._flag = flag
def __set_name__(self, name, owner=None):
self._name = name
def __get__(self, obj, objtype=None):
if obj:
return getattr(obj, self._column.name) & self._flag > 0
else:
return _BitComparator(self._column, self._flag)
def __set__(self, obj, val):
if obj:
orig = getattr(obj, self._column.name)
if val:
orig |= self._flag
else:
orig &= ~(self._flag)
setattr(obj, self._column.name, orig)
else:
raise NotImplementedError
# Helper for interactive session management
from suou.sqlalchemy import bool_column, bound_fk, create_session, declarative_base, BitSelector, unbound_fk
from suou.sqlalchemy import create_session, declarative_base
CSI = create_session_interactively = partial(create_session, app_config.database_url)
@ -43,23 +96,21 @@ db = SQLAlchemy(model_class=Base)
class User(db.Model):
__tablename__ = 'user'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(32), unique=True)
email: Mapped[str | None] = mapped_column(String(256), nullable=True)
password: Mapped[str] = mapped_column(String(255))
join_date: Mapped[int] = mapped_column(DateTime, server_default=func.current_timestamp())
karma: Mapped[int] = mapped_column(Integer, server_default=text('1'))
is_admin: Mapped[bool] = bool_column()
is_disabled: Mapped[int] = bool_column()
color_theme: Mapped[int] = mapped_column(SmallInteger, server_default=text('0'))
id = Column(Integer, primary_key=True)
username = Column(String(32), unique=True)
email = Column(String(256), nullable=True)
password = Column(String(255))
# will change to server_default in 1.1
join_date = Column(DateTime, default=datetime.datetime.now)
karma = Column(Integer, default=1)
privileges = Column(BigInteger, default=0)
is_admin = BitSelector(privileges, 1)
restrictions = Column(BigInteger, default=0)
is_disabled = BitSelector(restrictions, 1)
owned_pages: Relationship[List[Page]] = relationship('Page', back_populates='owner')
group_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='user')
contributions: Relationship[PageRevision] = relationship("PageRevision", back_populates='user')
__table_args__ = (
UniqueConstraint(username, name='user_username'),
)
contributions = relationship("PageRevision", back_populates='user')
# helpers for flask_login
@property
@ -119,18 +170,14 @@ PERM_LOCK = PERM_READ
class UserGroup(db.Model):
__tablename__ = 'usergroup'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(32), unique=True)
permissions: Mapped[int] = mapped_column(BigInteger, server_default=text('0'))
can_read: Mapped[bool] = BitSelector(permissions, PERM_READ)
can_edit: Mapped[bool] = BitSelector(permissions, PERM_EDIT)
can_create: Mapped[bool] = BitSelector(permissions, PERM_CREATE)
can_set_url: Mapped[bool] = BitSelector(permissions, PERM_SET_URL)
can_set_tags: Mapped[bool] = BitSelector(permissions, PERM_SET_TAGS)
__table_args__ = (
UniqueConstraint(name, name='usergroup_name'),
)
id = Column(Integer, primary_key=True)
name = Column(String(32), unique=True)
permissions = Column(BigInteger, default=0)
can_read = BitSelector(permissions, PERM_READ)
can_edit = BitSelector(permissions, PERM_EDIT)
can_create = BitSelector(permissions, PERM_CREATE)
can_set_url = BitSelector(permissions, PERM_SET_URL)
can_set_tags = BitSelector(permissions, PERM_SET_TAGS)
user_memberships: Relationship[UserGroupMembership] = relationship('UserGroupMembership', back_populates='group')
page_permissions: Relationship[PagePermission] = relationship('PagePermission', back_populates = 'group')
@ -151,16 +198,15 @@ class UserGroup(db.Model):
class UserGroupMembership(db.Model):
__tablename__ = 'usergroupmembership'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
group_id: Mapped[int] = mapped_column(Integer, ForeignKey('usergroup.id'))
since: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now)
__table_args__ = (
UniqueConstraint(user_id, group_id, name="usergroupmembership_user_id_group_id"),
UniqueConstraint('user_id', 'group_id'),
)
id = Column(Integer, primary_key = True)
user_id = Column(Integer, ForeignKey('user.id'))
group_id = Column(Integer, ForeignKey('usergroup.id'))
since = Column(DateTime, default=datetime.datetime.now)
user: Relationship[User] = relationship("User", back_populates='group_memberships')
group: Relationship[UserGroup] = relationship("UserGroup", back_populates='user_memberships')
@ -168,26 +214,18 @@ class UserGroupMembership(db.Model):
class Page(db.Model):
__tablename__ = 'page'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# v--- url will be moved to a separate table in 1.2 (?)
url: Mapped[str | None] = mapped_column(String(64), unique=True)
title: Mapped[str] = mapped_column(String(256))
touched: Mapped[datetime.datetime] = mapped_column(DateTime)
calendar: Mapped[datetime.datetime | None] = mapped_column(DateTime, index=True)
owner_id: Mapped[int | None] = mapped_column(Integer, ForeignKey('user.id'))
flags: Mapped[int] = mapped_column(BigInteger, server_default=text('0'))
is_redirect: Mapped[bool] = BitSelector(flags, 1)
is_sync: Mapped[bool] = BitSelector(flags, 2)
is_math_enabled: Mapped[bool] = BitSelector(flags, 4)
is_locked: Mapped[bool] = BitSelector(flags, 8)
is_cw: Mapped[bool] = BitSelector(flags, 16)
__table_args__ = (
Index('page_title', title),
Index('page_touched', touched),
UniqueConstraint(url, name='page_url'),
Index('page_calendar', calendar)
)
id = Column(Integer, primary_key=True)
url = Column(String(64), unique=True, nullable=True)
title = Column(String(256), index=True)
touched = Column(DateTime, index=True)
calendar = Column(DateTime, index=True, nullable=True)
owner_id = Column(Integer, ForeignKey('user.id'), nullable=True)
flags = Column(BigInteger, default=0)
is_redirect = BitSelector(flags, 1)
is_sync = BitSelector(flags, 2)
is_math_enabled = BitSelector(flags, 4)
is_locked = BitSelector(flags, 8)
is_cw = BitSelector(flags, 16)
revisions: Relationship[List[PageRevision]] = relationship("PageRevision", back_populates = 'page')
owner: Relationship[User] = relationship('User', back_populates = 'owned_pages')
@ -199,7 +237,7 @@ class Page(db.Model):
def latest(self) -> PageRevision:
return db.session.execute(
select(PageRevision).where(PageRevision.page_id == self.id).order_by(PageRevision.pub_date.desc()).limit(1)
db.select(PageRevision).where(PageRevision.page_id == self.id).order_by(PageRevision.pub_date.desc()).limit(1)
).scalar()
def get_url(self):
@ -209,7 +247,6 @@ class Page(db.Model):
def by_url(cls, url: str):
return db.session.execute(db.select(Page).where(Page.url == url)).scalar()
@deprecated('usage of inefficient and deprecated remove_tags()')
def short_desc(self) -> str:
if self.is_cw:
return '(Content Warning: we are not allowed to show a description.)'
@ -261,6 +298,10 @@ class Page(db.Model):
tags = [x.name for x in self.tags]
)
@not_implemented
def ldjson():
...
@deprecated('meta name="keywords" is nowadays ignored by search engines')
def seo_keywords(self):
kw = []
@ -326,15 +367,19 @@ class Page(db.Model):
class PageText(db.Model):
__tablename__ = 'pagetext'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
content: Mapped[bytes] = mapped_column(Blob)
flags: Mapped[int] = mapped_column(BigInteger, default=0)
is_gzipped: Mapped[bool] = BitSelector(flags, 2)
id = Column(Integer, primary_key=True)
content = Column(Blob)
flags = Column(BigInteger, default=0)
is_utf8 = BitSelector(flags, 1)
is_gzipped = BitSelector(flags, 2)
def get_content(self) -> str:
c = self.content
if self.is_gzipped:
c = gzip.decompress(c)
return c.decode('utf-8', 'replace')
if self.is_utf8:
return c.decode('utf-8')
else:
return c.decode('latin-1')
@classmethod
def create_content(cls, text: str, *, treshold=600, search_dup = True) -> PageText:
c: bytes = text.encode('utf-8')
@ -347,6 +392,7 @@ class PageText(db.Model):
return item
return db.session.execute(insert(cls).values(
content = c,
is_utf8 = True,
is_gzipped = use_gzip
).returning(cls)).scalar()
@ -355,21 +401,16 @@ class PageText(db.Model):
class PageRevision(db.Model):
__tablename__ = 'pagerevision'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
page_id: Mapped[int] = bound_fk(Page.id)
user_id: Mapped[int | None] = unbound_fk(User.id)
comment: Mapped[str] = mapped_column(String(1024), default='')
textref_id: Mapped[int] = bound_fk(PageText.id)
pub_date: Mapped[datetime.datetime] = mapped_column(DateTime, index=True)
length: Mapped[int] = mapped_column()
id = Column(Integer, primary_key=True)
page_id = Column(Integer, ForeignKey('page.id'))
user_id = Column(Integer, ForeignKey('user.id'), nullable=True)
comment = Column(String(1024), default='')
textref_id = Column(Integer, ForeignKey('pagetext.id'))
pub_date = Column(DateTime, index=True)
length = Column(Integer)
__table_args__ = (
Index('ix_pagerevision_pub_date', pub_date),
)
page: Relationship[Page] = relationship("Page", back_populates='revisions')
user: Relationship[User] = relationship("User", back_populates='contributions')
page = relationship("Page", back_populates='revisions')
user = relationship("User", back_populates='contributions')
textref: Relationship[PageText] = relationship("PageText")
@ -398,13 +439,12 @@ class PageRevision(db.Model):
class PageTag(db.Model):
__tablename__ = 'pagetag'
__table_args__ = (
UniqueConstraint('page_id', 'name', name='pagetag_page_id_name'),
Index('ix_pagetag_name', 'name')
UniqueConstraint('page_id', 'name'),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
page_id: Mapped[int] = bound_fk(Page.id)
name: Mapped[str] = mapped_column(String(64), index=True)
id = Column(Integer, primary_key=True)
page_id = Column(Integer, ForeignKey('page.id'))
name: Column[str] = Column(String(64), index=True)
page = relationship('Page', back_populates='tags')
@ -415,24 +455,20 @@ class PageTag(db.Model):
class PageProperty(db.Model):
"""
XXX as of 1.1.0, PageProperty is unused
"""
__tablename__ = 'pageproperty'
__table_args__ = (
UniqueConstraint('page_id', 'key', name="pageproperty_page_id_key"),
UniqueConstraint('page_id', 'key'),
)
id: Mapped[int] = mapped_column(Integer, primary_key = True)
page_id: Mapped[int] = mapped_column(Integer, ForeignKey('page.id'))
key: Mapped[str] = mapped_column(String(64))
value: Mapped[str] = mapped_column(String(8000))
id = Column(Integer, primary_key = True)
page_id = Column(Integer, ForeignKey('page.id'))
key = Column(String(64))
value = Column(String(8000))
page = relationship('Page', back_populates = 'properties')
# XXX PagePropertyDict?
@deprecated('is it *really* worth it to implement?')
# XXX is it *really* worth it to implement PagePropertyDict?
class _PagePropertyDict(Mapping):
__slots__ = ('_page', )
def __init__(self, page: Page, /) -> tuple[str, Any]:
@ -474,21 +510,18 @@ class _PagePropertyDict(Mapping):
else:
return self._page.get_prop(key)
class PageLink(db.Model):
__tablename__ = 'pagelink'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
from_page_id: Mapped[int] = bound_fk(Page.id)
to_page_id: Mapped[int] = bound_fk(Page.id)
__table_args__ = (
UniqueConstraint('from_page_id', 'to_page_id', name='pagelink_from_page_id_to_page_id'),
Index('to_page_id', to_page_id)
UniqueConstraint('from_page_id', 'to_page_id'),
)
id = Column(Integer, primary_key = True)
from_page_id = Column(Integer, ForeignKey('page.id'))
to_page_id = Column(Integer, ForeignKey('page.id'))
from_page: Relationship[Page] = relationship('Page', foreign_keys=[from_page_id], back_populates='forward_links')
to_page: Relationship[Page] = relationship('Page', foreign_keys=[to_page_id], back_populates='back_links')
to_page: Relationship[Page] = relationship('Page', foreign_keys=[to_page_id], back_populates='back_links')
@classmethod
def parse_links(cls, from_page, text: str, erase=True):
@ -528,23 +561,20 @@ class PageLink(db.Model):
class PagePermission(db.Model):
__tablename__ = 'pagepermission'
id: Mapped[int] = mapped_column(Integer, primary_key = True)
page_id: Mapped[int] = bound_fk(Page.id)
group_id: Mapped[int] = bound_fk(UserGroup.id)
# v--- will be split in 1.2, otherwise sooner or later
permissions: Mapped[int] = mapped_column(BigInteger, server_default=text('0'))
can_read: Mapped[bool] = BitSelector(permissions, PERM_READ)
can_edit: Mapped[bool] = BitSelector(permissions, PERM_EDIT)
can_create: Mapped[bool] = BitSelector(permissions, PERM_CREATE)
can_set_url: Mapped[bool] = BitSelector(permissions, PERM_SET_URL)
can_set_tags: Mapped[bool] = BitSelector(permissions, PERM_SET_TAGS)
__table_args__ = (
UniqueConstraint('page_id', 'group_id', name="pagepermission_page_id_group_id"),
Index('group_id', group_id)
UniqueConstraint('page_id', 'group_id'),
)
id = Column(Integer, primary_key = True)
page_id = Column(Integer, ForeignKey('page.id'))
group_id = Column(Integer, ForeignKey('usergroup.id'))
permissions = Column(BigInteger, default=0)
can_read = BitSelector(permissions, PERM_READ)
can_edit = BitSelector(permissions, PERM_EDIT)
can_create = BitSelector(permissions, PERM_CREATE)
can_set_url = BitSelector(permissions, PERM_SET_URL)
can_set_tags = BitSelector(permissions, PERM_SET_TAGS)
page = relationship('Page', back_populates = 'permission_overrides')
group = relationship('UserGroup', back_populates = 'page_permissions')

View file

@ -22,7 +22,7 @@ def md_and_toc(text) -> tuple[Markup, Any | None]:
try:
converter: markdown.Markdown = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)
markup = Markup(converter.convert(text))
toc = Markup(converter.toc)
toc = converter.toc
return markup, toc
except Exception as e:
return error_p('Error during rendering: {0}: {1}')

View file

@ -14,7 +14,7 @@ from salvi.i18n import get_string
from ..utils import parse_tag_list
from ..renderer import md_and_toc
from ..models import PERM_CREATE, PERM_EDIT, Page, PageLink, PageRevision, PageText, User, db, is_url_available, is_valid_url, perms_required
from ..models import PERM_CREATE, Page, PageLink, PageRevision, PageText, User, db, is_url_available, is_valid_url, perms_required
current_user: User
@ -110,7 +110,7 @@ def create():
return savepoint(dict(url=request.args.get('url'), title='', text='', tags='', comment=get_string(g.lang, 'page_created')))
@bp.route('/edit/<int:id>', methods=['GET', 'POST'])
@perms_required(PERM_EDIT, message='You are not authorized to edit pages.')
@login_required
def edit(id: int):
p = db.session.execute(select(Page).where(Page.id == id)).scalar()
if p is None:

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block title %}{{ title_tag(T('bad-request'), false) }}{% endblock %}
{% block title %}{{ title_tag(T('Bad Request'), false) }}{% endblock %}
{% block content %}
<main class="error-page">

View file

@ -25,6 +25,7 @@
}</style>
<link rel="stylesheet" href="/static/style.css">
{% block json_info %}{% endblock %}
{% block ldjson %}{% endblock %}
</head>
<body {% if color_theme %} class="{{ theme_classes(color_theme) }}"{% endif %}>
<div id="__top"></div>
@ -62,7 +63,7 @@
</div>
<footer class="site-footer">
<div class="footer-copyright">&copy; 2020-2025 Sakuragasaki46.</div>
<div class="footer-liability">{{ T('user-generated-warning')}} <u>{{ T('user-liability-warning') }}</u>.</div>
<div class="footer-liability">Any content in this site is user-generated and <u>each user is responsible for what they publish</u>.</div>
{% if not g.no_user %}
<div class="footer-loggedinas">
{% if current_user.is_authenticated %}

View file

@ -22,13 +22,13 @@
</ul>
<p>
{{ T('show-more-years') }}:
Show more years:
<ul class="inline">
<li>
<a href="?till_year={{ till_year + 15 }}">{{ T('in-the-future') }}</a>
<a href="?till_year={{ till_year + 15 }}">in the future</a>
</li>
<li>
<a href="?from_year={{ from_year - 15 }}">{{ T('in-the-past') }}</a>
<a href="?from_year={{ from_year - 15 }}">in the past</a>
</li>
</ul>
</p>

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}List of contacts {{ app_name }}{% endblock %}
{% block content %}
<p>Showing: <strong>{{ cat }}</strong> · <a href="/kt/new">New contact</a></p>
<fieldset>
<legend>Show by:</legend>
<p><strong>Letter</strong>:
{% set typ_list = 'ABCDEFGHIJKLMNOPQRSTUVWYZ' %}
{% for t in typ_list %}
<a href="/kt/{{ t }}">{{ t }}</a> ·
{% endfor %}
</p>
<p>
<a href="/kt/">All</a> ·
<a href="/kt/expired">Expired</a> ·
<a href="/kt/ok">Sane</a>
</p>
</fieldset>
{% if count > people.count() %}
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
{% if count > pageno * 50 %}
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
{% elif pageno > 1 %}
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
{% endif %}
{% else %}
<p><strong>{{ count }}</strong> people.</p>
{% endif %}
<table class="contactnova-list">
<thead>
</thead>
<tbody>
{% for p in people %}
<tr class="contactnova-status_{{ p.status }}">
<td><span class="material-icons">{{ p.status_str() }}</span></td>
<td class="contactnova-col-code"><a href="/kt/{{ p.code }}">{{ p.code }}</a></td>
<td>{{ p.display_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if count > people.count() %}
<p>Showing <strong>{{ people.count() }}</strong> people of <strong>{{ count }}</strong> total.</p>
{% if count > pageno * 50 %}
<p><a href="?page={{ pageno + 1 }}" rel="nofollow">Next page</a>{% if pageno > 1 %} · <a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a>{% endif %}</p>
{% elif pageno > 1 %}
<a href="?page={{ pageno - 1 }}" rel="nofollow">Prev page</a></p>
{% endif %}
{% else %}
<p><strong>{{ count }}</strong> people.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Create new contact {{ app_name }}{% endblock %}
{% block content %}
<form method="POST" class="circles-add-form">
{% if returnto %}<input type="hidden" name="returnto" value="{{ returnto }}">{% endif %}
{% if not pl %}
<div>
<label>Letter</label>
<select id="ktCodeLetter" name="letter">
<option value="-" disabled="" selected="">(Choose)</option>
{% set typ_list = 'ABCDEFGHIJKLMNOPQRSTUVWYZ' %}
{% for t in typ_list %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label>Code</label>
<strong id="ktNewCode">---</strong>
</div>
<div>
<label>Display name</label>
<input name="display_name" maxlength="50"{% if pl %} value="{{ pl.display_name }}"{% endif %}>
</div>
<div>
<label>Status</label>
<select name="status">
{% set statuses = {
0: 'Variable',
1: 'OK',
2: 'Issues',
} %}
{% for k, v in statuses.items() %}
<option value="{{ k }}"{% if pl and pl.status == k %} selected=""{% endif %}>{{ v }}</option>
{% endfor %}
</select>
</div>
<div>
<label>Issues</label>
<textarea maxlength="500" name="issues">{{ pl and pl.issues }}</textarea>
</div>
<div>
<label>Description</label>
<textarea maxlength="5000" name="description">{{ pl and pl.description }}</textarea>
</div>
<div>
<label>Due</label>
<input name="due" required="" type="date" value="{{ pl.due if pl else pl_date }}" min="2020-01-01" />
</div>
<input type="submit" id="ktSubmit" value="Save">
</form>
<script>
{% if not pl %}
ktSubmit.disabled = true;
{% endif %}
ktCodeLetter.onchange = function(){
let x = new XMLHttpRequest;
x.open('GET', '/kt/_newcode/' + ktCodeLetter.value);
x.onreadystatechange = () => {
if (x.readyState === XMLHttpRequest.DONE && x.status == 200) {
ktNewCode.textContent = x.responseText;
ktSubmit.disabled = false;
}
};
x.send();
}
</script>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Contact {{ p.code }} {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ p.code }}</h1>
<p>aka: <strong>{{ p.display_name }}</strong></p>
{% if p.issues %}
<p class="contactnova-issues">{{ p.issues }}</p>
{% endif %}
<p class="contactnova-description">
{{ p.description or "No description available." |linebreaks }}
</p>
<p>&nbsp;</p>
<p>Expires: {{ p.due.strftime('%B %-d, %Y') }}</p>
<p><a href="/kt/">Back</a> · <a href="/kt/edit/{{ p.code }}?returnto=/kt/{{ p.code }}">Edit contact</a></p>
{% endblock %}

View file

@ -2,11 +2,11 @@
{% from "macros/title.html" import title_tag with context %}
{% from "macros/nl.html" import nl_list with context %}
{% block title %}{{ title_tag(T('notes-by-date')) }}{% endblock %}
{% block title %}{{ title_tag('Notes by date') }}{% endblock %}
{% block content %}
<main>
<h1 id="firstHeading">{{ T('notes-by-date') }}</h1>
<h1 id="firstHeading">Notes by date</h1>
<div class="inner-content">
{{ nl_list(notes) }}

View file

@ -1,10 +1,10 @@
{% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block title %}{{ title_tag(T('manage-accounts'), False) }}{% endblock %}
{% block title %}{{ title_tag('Manage accounts', False) }}{% endblock %}
{% block content %}
<h1>{{ T('manage-accounts') }}</h1>
<h1>Manage accounts</h1>
<div class="inner-content">
{% if current_user.is_admin %}
@ -13,7 +13,7 @@
<strong>Beware: you are managing sensitive informations.</strong>
</p>
<p class="nl-pagination">{{ T('search-results') }} <strong>{{ users.page * 20 - 19 }}</strong> to <strong>{{ min(users.page * 20, users.total) }}</strong> of <strong>{{ users.total }}</strong> total.</p>
<p class="nl-pagination">Showing results <strong>{{ users.page * 20 - 19 }}</strong> to <strong>{{ min(users.page * 20, users.total) }}</strong> of <strong>{{ users.total }}</strong> total.</p>
<form enctype="multipart/form-data" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />

View file

@ -28,7 +28,7 @@
{% elif q %}
<h2>{{ T('search-no-results') }} <em>{{ q }}</em></h2>
{% else %}
<p><u>Please note that search queries <u>do not search for page text</u>.</u></p>
<p><u>Please note that search queries do not search for page text.</u></p>
{% endif %}
</div>
</main>