Fix timezone issues; add user-friendly due date entry; add client-side auto-update to the table fields
Closes #15 and makes the UI for making and viewing task info quite a bit nicer.
This commit is contained in:
parent
9136628cd3
commit
991717687b
22 changed files with 1006 additions and 79 deletions
|
|
@ -9,6 +9,8 @@
|
|||
- humanize (for generating human-readable timedeltas)
|
||||
- nh3 (for markdown and general HTML sanitization)
|
||||
- scour (for SVG icon processing)
|
||||
- requests
|
||||
- markdown
|
||||
|
||||
## Already Included
|
||||
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
from datetime import datetime
|
||||
import logging
|
||||
import humanize
|
||||
from typing import Any
|
||||
from flask import Flask, render_template, url_for
|
||||
|
||||
|
|
@ -11,6 +9,7 @@ from taskflower.api import APIBase
|
|||
from taskflower.db.model.user import User
|
||||
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
|
||||
from taskflower.tools.icons import get_icon, svg_bp
|
||||
from taskflower.util.time import render_abstime, render_reltime
|
||||
from taskflower.web import web_base
|
||||
|
||||
from taskflower.tools.hibp import hibp_bp
|
||||
|
|
@ -59,21 +58,6 @@ def template_utility_fns():
|
|||
+ ')'
|
||||
)
|
||||
|
||||
def reltime(until: datetime) -> str:
|
||||
''' Turn a timestamp into a human-readable relative value, relative
|
||||
to the moment this function is called.
|
||||
'''
|
||||
now = datetime.now()
|
||||
delta = now - until
|
||||
if now > until:
|
||||
return humanize.naturaldelta(
|
||||
delta
|
||||
) + ' ago'
|
||||
else:
|
||||
return 'in ' + humanize.naturaldelta(
|
||||
delta
|
||||
)
|
||||
|
||||
def can_generate_sign_up_codes(usr: User) -> bool:
|
||||
match config.sign_up_mode:
|
||||
case SignUpMode.OPEN:
|
||||
|
|
@ -98,7 +82,8 @@ def template_utility_fns():
|
|||
|
||||
return dict(
|
||||
literal_call=literal_call,
|
||||
reltime=reltime,
|
||||
reltime=render_reltime,
|
||||
abstime=render_abstime,
|
||||
can_generate_sign_up_codes=can_generate_sign_up_codes,
|
||||
render_as_markdown=render_as_markdown,
|
||||
icon=icon
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ def _create_user_role(user: User) -> Either[Exception, UserRole]:
|
|||
def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
|
||||
try:
|
||||
new_ns = Namespace(
|
||||
name=f'{user.display_name}\'s Namespace'[:64], # pyright:ignore[reportCallIssue]
|
||||
description='Your default namespace!' # pyright:ignore[reportCallIssue]
|
||||
name=f'{user.display_name}\'s Zone'[:64], # pyright:ignore[reportCallIssue]
|
||||
description='Your default zone!' # pyright:ignore[reportCallIssue]
|
||||
)
|
||||
|
||||
db.session.add(new_ns)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from secrets import token_hex
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
|
@ -8,6 +8,7 @@ from taskflower.config import SignUpMode, config
|
|||
from taskflower.db.model.codes import SignUpCode
|
||||
from taskflower.db.model.user import User
|
||||
from taskflower.types.either import Either, Left, Right
|
||||
from taskflower.util.time import now
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
|||
return db.session.query(
|
||||
SignUpCode
|
||||
).filter(
|
||||
SignUpCode.expires > datetime.now()
|
||||
SignUpCode.expires > now()
|
||||
).filter(
|
||||
SignUpCode.grants_admin
|
||||
).filter(
|
||||
|
|
@ -30,7 +31,7 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
|||
delete_count = db.session.query(
|
||||
SignUpCode
|
||||
).filter(
|
||||
SignUpCode.expires > datetime.now()
|
||||
SignUpCode.expires > now()
|
||||
).filter(
|
||||
SignUpCode.grants_admin
|
||||
).filter(
|
||||
|
|
@ -63,8 +64,8 @@ def _ensure_at_least_one_admin(db: SQLAlchemy) -> Either[Exception, None]:
|
|||
else:
|
||||
code = SignUpCode(
|
||||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||
created=datetime.now(), # pyright:ignore[reportCallIssue]
|
||||
expires=datetime.now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
|
||||
created=now(), # pyright:ignore[reportCallIssue]
|
||||
expires=now() + timedelta(hours=1), # pyright:ignore[reportCallIssue]
|
||||
created_by=-1, # pyright:ignore[reportCallIssue]
|
||||
grants_admin=True # pyright:ignore[reportCallIssue]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class HIBPLocalCacheMode(EnumFromEnv):
|
|||
@dataclass(frozen=True)
|
||||
class ConfigType:
|
||||
# Application secrets
|
||||
db_secret : str # Secret value used to 'pepper' password hashes. This MUST
|
||||
db_secret : str = 'potato' # Secret value used to 'pepper' password hashes. This MUST
|
||||
# be generated randomly and cryptographically securely.
|
||||
# For an example of how to do this:
|
||||
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY
|
||||
|
|
@ -116,7 +116,7 @@ class ConfigType:
|
|||
# In a multi-node environment, this key must be the same
|
||||
# across all nodes.
|
||||
|
||||
app_secret : str # Secret value used to generate session tokens. This should
|
||||
app_secret : str = 'potato' # Secret value used to generate session tokens. This should
|
||||
# ALSO be generated securely, and it should be different from
|
||||
# ``db_secret``
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from taskflower.db import db
|
||||
from taskflower.util.time import now
|
||||
|
||||
class SignUpCode(db.Model):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(32), unique=True)
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||
grants_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
|
||||
class NamespaceInviteCode(db.Model):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(32), unique=True)
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
expires: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
for_role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from taskflower.db import db
|
||||
from taskflower.util.time import now
|
||||
|
||||
|
||||
class Namespace(db.Model):
|
||||
|
|
@ -11,12 +12,12 @@ class Namespace(db.Model):
|
|||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
description: Mapped[str] = mapped_column(String)
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
|
||||
|
||||
class TaskToNamespace(db.Model):
|
||||
__tablename__: str = 'task_to_namespace'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
||||
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id'))
|
||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
|
||||
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE'))
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from taskflower.db import db
|
||||
from taskflower.util.time import now
|
||||
|
||||
|
||||
class NamespaceRole(db.Model):
|
||||
|
|
@ -12,15 +13,15 @@ class NamespaceRole(db.Model):
|
|||
permissions: Mapped[int] = mapped_column(Integer)
|
||||
perms_deny: Mapped[int] = mapped_column(Integer)
|
||||
priority: Mapped[int] = mapped_column(Integer)
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
||||
|
||||
class UserToNamespaceRole(db.Model):
|
||||
__tablename__: str = 'user_to_namespace_role'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id'))
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('namespace_role.id', ondelete='CASCADE'))
|
||||
|
||||
class UserRole(db.Model):
|
||||
__tablename__: str = 'user_role'
|
||||
|
|
@ -30,12 +31,12 @@ class UserRole(db.Model):
|
|||
permissions: Mapped[int] = mapped_column(Integer)
|
||||
perms_deny: Mapped[int] = mapped_column(Integer)
|
||||
priority: Mapped[int] = mapped_column(Integer)
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
|
||||
class UserToUserRole(db.Model):
|
||||
__tablename__: str = 'user_to_user_role'
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id'))
|
||||
user: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
role: Mapped[int] = mapped_column(Integer, ForeignKey('user_role.id', ondelete='CASCADE'))
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
|
||||
|
||||
from taskflower.db import db
|
||||
from taskflower.util.time import now
|
||||
|
||||
class Task(db.Model):
|
||||
__tablename__: str = 'task'
|
||||
|
|
@ -11,7 +11,8 @@ class Task(db.Model):
|
|||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
description: Mapped[str] = mapped_column(String)
|
||||
due: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
due: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
||||
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
|
||||
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
|
||||
|
|
@ -2,11 +2,12 @@ from datetime import datetime
|
|||
from typing import override
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from taskflower.db import db
|
||||
|
||||
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
|
||||
|
||||
from taskflower.util.time import now
|
||||
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__: str = 'user'
|
||||
|
|
@ -14,7 +15,7 @@ class User(db.Model, UserMixin):
|
|||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(32), unique=True)
|
||||
display_name: Mapped[str] = mapped_column(String(256))
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||
|
||||
# Status
|
||||
enabled: Mapped[bool] = mapped_column(Boolean)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from datetime import timezone
|
||||
from typing import override
|
||||
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
|
||||
from zoneinfo import ZoneInfo
|
||||
from wtforms import Field, Form, SelectField, StringField, TextAreaField, ValidationError, validators
|
||||
|
||||
from taskflower.auth.permission import NamespacePermissionType
|
||||
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
|
||||
|
|
@ -12,6 +14,35 @@ from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
|
|||
from taskflower.types import ann
|
||||
from taskflower.types.either import Either, Left, Right
|
||||
from taskflower.types.option import Option
|
||||
from taskflower.util.time import from_sophont_provided_data
|
||||
|
||||
def is_valid_iana(_: Form, field: Field):
|
||||
if not isinstance(field.data, str): # pyright:ignore[reportAny]
|
||||
raise ValidationError('Incorrect type')
|
||||
|
||||
try:
|
||||
ZoneInfo(field.data) # pyright:ignore[reportUnusedCallResult]
|
||||
except Exception:
|
||||
raise ValidationError('Invalid timezone.')
|
||||
|
||||
def is_valid_timestamp(form: Form, field: Field):
|
||||
if not isinstance(field.data, str): # pyright:ignore[reportAny]
|
||||
raise ValidationError('Incorrect type')
|
||||
|
||||
if (
|
||||
(not hasattr(form, 'timezone'))
|
||||
or (not isinstance(tzfld:=getattr(form, 'timezone'), Field)) # pyright:ignore[reportAny]
|
||||
or (not isinstance(tzdata:=tzfld.data, str)) # pyright:ignore[reportAny]
|
||||
):
|
||||
raise ValidationError('Invalid timezone.')
|
||||
|
||||
res = from_sophont_provided_data(
|
||||
field.data,
|
||||
tzdata
|
||||
)
|
||||
|
||||
if isinstance(res, Left):
|
||||
raise ValidationError(f'Parse failure: {res.val!s}')
|
||||
|
||||
def task_edit_form_for_task(
|
||||
t: Task
|
||||
|
|
@ -28,12 +59,20 @@ def task_edit_form_for_task(
|
|||
],
|
||||
default=t.name
|
||||
)
|
||||
due: DateTimeLocalField = DateTimeLocalField(
|
||||
due: StringField = StringField(
|
||||
'Due Date',
|
||||
[
|
||||
validators.DataRequired()
|
||||
validators.Optional(),
|
||||
is_valid_timestamp
|
||||
],
|
||||
default=t.due # pyright:ignore[reportArgumentType]
|
||||
default=None
|
||||
)
|
||||
timezone: StringField = StringField(
|
||||
'Timezone, in IANA format',
|
||||
[
|
||||
is_valid_iana
|
||||
],
|
||||
default='Etc/UTC'
|
||||
)
|
||||
description: TextAreaField = TextAreaField(
|
||||
'Description',
|
||||
|
|
@ -47,7 +86,15 @@ def task_edit_form_for_task(
|
|||
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
|
||||
def _do_edit(tsk: Task) -> Either[Exception, Task]:
|
||||
tsk.name = ann(self.name.data)
|
||||
tsk.due = ann(self.due.data)
|
||||
|
||||
if self.due.data:
|
||||
# We already check the validity during validation,
|
||||
# so this call should always be Right
|
||||
tsk.due = from_sophont_provided_data(
|
||||
self.due.data,
|
||||
ann(self.timezone.data)
|
||||
).assert_right().val.astimezone(timezone.utc)
|
||||
|
||||
tsk.description = (
|
||||
self.description.data
|
||||
) if self.description.data else ''
|
||||
|
|
@ -94,12 +141,19 @@ def task_form_for_user(
|
|||
)
|
||||
]
|
||||
)
|
||||
due: DateTimeLocalField = DateTimeLocalField(
|
||||
due: StringField = StringField(
|
||||
'Due Date',
|
||||
[
|
||||
validators.DataRequired()
|
||||
is_valid_timestamp
|
||||
]
|
||||
)
|
||||
timezone: StringField = StringField(
|
||||
'Timezone, in IANA format',
|
||||
[
|
||||
is_valid_iana
|
||||
],
|
||||
default='Etc/UTC'
|
||||
)
|
||||
namespace: SelectField = SelectField(
|
||||
'Select a namespace',
|
||||
[
|
||||
|
|
@ -121,6 +175,16 @@ def task_form_for_user(
|
|||
current_user: User
|
||||
) -> Either[Exception, Task]:
|
||||
if self.validate():
|
||||
due_date_parsed = from_sophont_provided_data(
|
||||
ann(self.due.data),
|
||||
ann(self.timezone.data)
|
||||
).assert_right().val.astimezone(timezone.utc)
|
||||
|
||||
print(f'Timezone is: {self.timezone.data}')
|
||||
print(f'Time is {due_date_parsed.astimezone(ZoneInfo("America/Chicago"))!s}')
|
||||
print(f'Time (UTC) is {due_date_parsed!s}')
|
||||
print(f'Timestamp is {int(due_date_parsed.timestamp())}')
|
||||
|
||||
return Option[Namespace].encapsulate(
|
||||
db.session.query(
|
||||
Namespace
|
||||
|
|
@ -140,13 +204,14 @@ def task_form_for_user(
|
|||
).map(
|
||||
lambda ns: Task(
|
||||
name=self.name.data, # pyright:ignore[reportCallIssue]
|
||||
due=self.due.data, # pyright:ignore[reportCallIssue]
|
||||
due=due_date_parsed, # pyright:ignore[reportCallIssue]
|
||||
description=( # pyright:ignore[reportCallIssue]
|
||||
self.description.data
|
||||
if self.description.data
|
||||
else ''
|
||||
),
|
||||
namespace=ns.id # pyright:ignore[reportCallIssue]
|
||||
namespace=ns.id, # pyright:ignore[reportCallIssue]
|
||||
owner=current_user.id # pyright:ignore[reportCallIssue]
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from secrets import token_hex
|
||||
from typing import override
|
||||
from wtforms import SelectField
|
||||
|
|
@ -15,6 +15,7 @@ from taskflower.form import FormCreatesObjectWithUserAndNamespace
|
|||
from taskflower.types import ann
|
||||
from taskflower.types.either import Either, Left, Right, gather_successes
|
||||
from taskflower.types.option import Option
|
||||
from taskflower.util.time import now
|
||||
|
||||
|
||||
def gen_namespace_invite(
|
||||
|
|
@ -37,7 +38,7 @@ def gen_namespace_invite(
|
|||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||
created_by=creator.id, # pyright:ignore[reportCallIssue]
|
||||
for_role=role.id, # pyright:ignore[reportCallIssue]
|
||||
expires=datetime.now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
|
||||
expires=now() + timedelta(days=7) # pyright:ignore[reportCallIssue]
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Self
|
||||
|
||||
import humanize
|
||||
|
|
@ -10,11 +10,13 @@ from taskflower.auth.permission.lookups import get_user_perms_on_task
|
|||
from taskflower.db.model.task import Task
|
||||
from taskflower.db.model.user import User
|
||||
from taskflower.types.either import Either
|
||||
from taskflower.util.time import ensure_timezone_aware, now
|
||||
|
||||
def _due_str(due: datetime) -> str:
|
||||
now = datetime.now()
|
||||
delta = datetime.now() - due
|
||||
if now > due:
|
||||
due = ensure_timezone_aware(due)
|
||||
cur_dt = now()
|
||||
delta = now() - due
|
||||
if cur_dt > due:
|
||||
return humanize.naturaldelta(
|
||||
delta
|
||||
) + ' ago'
|
||||
|
|
@ -65,7 +67,7 @@ class TaskForUser:
|
|||
tsk.id,
|
||||
tsk.name,
|
||||
tsk.description,
|
||||
tsk.due,
|
||||
tsk.due.replace(tzinfo=timezone.utc),
|
||||
_due_str(tsk.due),
|
||||
tsk.created,
|
||||
tsk.complete,
|
||||
|
|
|
|||
173
src/taskflower/static/time-utils.js
Normal file
173
src/taskflower/static/time-utils.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
]
|
||||
|
||||
const numbers = [
|
||||
'zero',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
]
|
||||
|
||||
function day_suffix(day){
|
||||
switch(day){
|
||||
case 1:
|
||||
return 'st'
|
||||
case 2:
|
||||
return 'nd'
|
||||
case 3:
|
||||
return 'rd'
|
||||
default:
|
||||
if((day % 100) > 20){
|
||||
return day_suffix(day % 10)
|
||||
}else{
|
||||
return 'th'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _pad(v){
|
||||
return `${v}`.padStart(2, '0')
|
||||
}
|
||||
|
||||
function long_datetime(date){
|
||||
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())}, ${date.getFullYear()} at ${date.getHours()}:${_pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function short_datetime(date){
|
||||
return `${months[date.getMonth()]} ${date.getDay()}${day_suffix(date.getDay())} at ${date.getHours()}:${_pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function render_abstimes(){
|
||||
Array.from(document.querySelectorAll(
|
||||
'.render-timestamp'
|
||||
)).forEach(
|
||||
(el) => {
|
||||
const el_time = new Date(Number(el.dataset.timestamp)*1000)
|
||||
el.textContent = `${long_datetime(el_time)}`
|
||||
el.classList.remove('render-timestamp') // Timestamps only need to be rendered to the local timezone once.
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function textize_number(num){
|
||||
switch(num){
|
||||
case 1:
|
||||
return 'one'
|
||||
case 2:
|
||||
return 'two'
|
||||
case 3:
|
||||
return 'three'
|
||||
case 4:
|
||||
return 'four'
|
||||
case 5:
|
||||
return 'five'
|
||||
case 6:
|
||||
return 'six'
|
||||
case 7:
|
||||
}
|
||||
}
|
||||
|
||||
function sophontize(delta, literal){
|
||||
const isFuture = delta > 0
|
||||
const absDelta = Math.floor(Math.abs(delta) / 1000)
|
||||
const seconds = absDelta % 60
|
||||
const minutes = Math.floor(absDelta/60) % 60
|
||||
const hours = Math.floor(absDelta/(60*60)) % 24
|
||||
const days = Math.floor(absDelta/(60*60*24))
|
||||
const weeks = Math.floor(days/7)
|
||||
|
||||
function _cap(v){
|
||||
return v.charAt(0).toUpperCase() + v.slice(1)
|
||||
}
|
||||
|
||||
function _relative(v){
|
||||
if(isFuture){
|
||||
return `In ${v}`
|
||||
}else{
|
||||
return _cap(`${v} ago`)
|
||||
}
|
||||
}
|
||||
|
||||
function _plur(v){
|
||||
if(v == 1){
|
||||
return ''
|
||||
}else{
|
||||
return 's'
|
||||
}
|
||||
}
|
||||
|
||||
function _num(v){
|
||||
if(v < 3){
|
||||
return numbers[v]
|
||||
}else{
|
||||
return v.toString()
|
||||
}
|
||||
}
|
||||
|
||||
if(days > 365){
|
||||
return long_datetime(literal)
|
||||
}else if(days > 28){
|
||||
return short_datetime(literal)
|
||||
}else if(weeks >= 1){
|
||||
return _relative(`${_num(weeks)} week${_plur(weeks)}`)
|
||||
}else if(days >= 1){
|
||||
return _relative(`${_num(days)} day${_plur(days)}`)
|
||||
}else if(hours >= 1){
|
||||
return _relative(`${_num(hours)} hour${_plur(hours)}`)
|
||||
}else if(minutes >= 5){
|
||||
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
|
||||
}else if(seconds >= 1 && isFuture){
|
||||
return _relative(`${_pad(minutes)}:${_pad(seconds)}`)
|
||||
}else if(minutes >= 1){
|
||||
return _relative(`${_num(minutes)} minute${_plur(minutes)}`)
|
||||
}else if(seconds >= 1){
|
||||
return _relative(`${_num(seconds)} second${_plur(seconds)}`)
|
||||
}else{
|
||||
return 'Now'
|
||||
}
|
||||
}
|
||||
|
||||
function update_reltimes(){
|
||||
Array.from(
|
||||
document.querySelectorAll(
|
||||
'.render-delta'
|
||||
)
|
||||
).forEach((el) => {
|
||||
const time = new Date(Number(el.dataset.timestamp)*1000)
|
||||
const delta = time - Date.now()
|
||||
el.textContent = sophontize(delta, time)
|
||||
})
|
||||
}
|
||||
|
||||
function check_and_fill_timezone(){
|
||||
const el = document.getElementById('timezone')
|
||||
if(el){
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
console.log(`User timezone detected as ${tz}. Filling it in automatically.`)
|
||||
el.value = tz
|
||||
document.getElementById('tz-container').style = 'display: none;'
|
||||
}
|
||||
}
|
||||
|
||||
render_abstimes()
|
||||
update_reltimes()
|
||||
check_and_fill_timezone()
|
||||
setInterval(update_reltimes, 1000)
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script type="text/javascript" src="/static/time-utils.js" defer></script>
|
||||
<title>{% block raw_title %}{% endblock %}</title>
|
||||
{% block head_extras %}{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
{{ render_field(form.name) }}
|
||||
{{ render_field(form.due) }}
|
||||
{{ render_field(form.description) }}
|
||||
<div id="tz-container">{{ render_field(form.timezone) }}</div>
|
||||
{{ render_field(form.namespace) }}
|
||||
</dl>
|
||||
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
|
||||
</td>
|
||||
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
|
||||
<p>{{ task.due_rel }}</p>
|
||||
<p>{{ reltime(task.due)|safe }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr> <!-- placeholder for CSS styling -->
|
||||
|
|
@ -61,8 +61,8 @@
|
|||
</div>
|
||||
<p class="small-details">
|
||||
Task ID: {{ task.id }}
|
||||
<br/>Created: {{ task.created }} in namespace {{ task.namespace_id }}
|
||||
<br/>Due: {{ task.due }}
|
||||
<br/>Created: {{ abstime(task.created)|safe }} in namespace {{ task.namespace_id }}
|
||||
<br/>Due: {{ abstime(task.due)|safe }}
|
||||
</p>
|
||||
<hr/>
|
||||
<div class="main-description">{{ render_as_markdown(task.description)|safe }}</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
{{ render_field(form.name) }}
|
||||
{{ render_field(form.due) }}
|
||||
{{ render_field(form.description) }}
|
||||
<div id="tz-container">{{ render_field(form.timezone) }}</div>
|
||||
</dl>
|
||||
<button class="icon-btn green" type="submit" id="submit-form">{{icon('add')|safe}}Edit Task</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import multiprocessing
|
||||
from multiprocessing.synchronize import BoundedSemaphore
|
||||
import os
|
||||
|
|
@ -18,6 +18,7 @@ from taskflower.auth import password_breach_count
|
|||
from taskflower.db import db
|
||||
from taskflower.db.model.hibp import PwnedPassword
|
||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||
from taskflower.util.time import now
|
||||
|
||||
API_BASE = 'https://api.pwnedpasswords.com/range'
|
||||
HASH_CHARS = '0123456789ABCDEF'
|
||||
|
|
@ -300,7 +301,7 @@ def hibp_to_database(
|
|||
print(f'[main] Error while clearing database: {e}. Cancelling.')
|
||||
return
|
||||
|
||||
op_start = datetime.now()
|
||||
op_start = now()
|
||||
|
||||
print(f'[main] Started reading data at {op_start}')
|
||||
|
||||
|
|
@ -328,7 +329,7 @@ def hibp_to_database(
|
|||
except Exception as e:
|
||||
print(f'[{fname}] Error while processing file: {e}')
|
||||
finally:
|
||||
cur_time = datetime.now()
|
||||
cur_time = now()
|
||||
elapsed = cur_time - op_start
|
||||
pct_done = (dex+1)/len(fnames_to_read)
|
||||
pct_left = 1.0 - pct_done
|
||||
|
|
|
|||
0
src/taskflower/util/__init__.py
Normal file
0
src/taskflower/util/__init__.py
Normal file
689
src/taskflower/util/time.py
Normal file
689
src/taskflower/util/time.py
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
from datetime import datetime, time, timedelta, timezone
|
||||
import re
|
||||
import humanize
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from taskflower.sanitize.markdown import SafeHTML
|
||||
from taskflower.types.either import Either, Left, Right
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TimezoneAwareDatetime = datetime
|
||||
TAD = TimezoneAwareDatetime
|
||||
TimezoneNaiveDatetime = datetime
|
||||
TND = TimezoneNaiveDatetime
|
||||
|
||||
def is_timezone_aware(dt: TAD|TND) -> bool:
|
||||
return (
|
||||
(dt.tzinfo is not None)
|
||||
and (dt.tzinfo.utcoffset(dt) is not None)
|
||||
)
|
||||
|
||||
def ensure_timezone_aware(dt: TAD|TND) -> TAD:
|
||||
''' Ensures that ``dt`` is timezone-aware, by assuming that timezone-naive
|
||||
``datetime``s are in UTC.
|
||||
'''
|
||||
if not is_timezone_aware(dt):
|
||||
log.debug(f'Non-timezone-aware datetime object {dt!s} was passed to `ensure_timezone_aware()`. It will be assumed to be UTC.')
|
||||
return dt.astimezone(timezone.utc)
|
||||
else:
|
||||
return dt
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def get_reltime(until: TAD|TND) -> timedelta:
|
||||
return ensure_timezone_aware(until) - now()
|
||||
|
||||
def from_local(local: TND, tz: str = 'Etc/UTC') -> TAD:
|
||||
''' Converts a datetime received in a users's local time to one in UTC.
|
||||
If we don't get any timezone info, assume it's already UTC.
|
||||
|
||||
If ``tz`` is invalid, it is also assumed to be UTC.
|
||||
'''
|
||||
try:
|
||||
local_tz = ZoneInfo(tz)
|
||||
except Exception:
|
||||
local_tz = timezone.utc
|
||||
return local.replace(
|
||||
tzinfo=local_tz
|
||||
).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
def render_reltime(dt: TAD) -> SafeHTML:
|
||||
dt_aware = ensure_timezone_aware(dt)
|
||||
humanized = humanize.naturaltime(dt_aware)
|
||||
|
||||
# Client-side javascript is responsible for updating the timestamp on the
|
||||
# user's end, but we provide a sensible default for non-JS browsers.
|
||||
return f'<span class="render-delta" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{humanized}</span>'
|
||||
|
||||
def render_abstime(dt: TAD) -> SafeHTML:
|
||||
dt_aware = ensure_timezone_aware(dt)
|
||||
user_text = dt_aware.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Client-side javascript is responsible for doing timezone conversions on
|
||||
# the user's end, but we provide a sensible default for non-JS browsers.
|
||||
return f'<span class="render-timestamp" data-timestamp="{int(dt_aware.astimezone(timezone.utc).timestamp())}">{user_text} UTC</span>'
|
||||
|
||||
WEEKDAYS=[
|
||||
'MONDAY',
|
||||
'TUESDAY',
|
||||
'WEDNESDAY',
|
||||
'THURSDAY',
|
||||
'FRIDAY',
|
||||
'SATURDAY',
|
||||
'SUNDAY'
|
||||
]
|
||||
|
||||
WEEKDAYS_RE = '|'.join(WEEKDAYS)
|
||||
|
||||
TIMESPANS=[
|
||||
'SECOND',
|
||||
'MINUTE',
|
||||
'HOUR',
|
||||
'DAY',
|
||||
'WEEK',
|
||||
'MONTH',
|
||||
'YEAR',
|
||||
'DECADE',
|
||||
'CENTURY',
|
||||
'MILLENNIUM'
|
||||
]
|
||||
|
||||
TIMESPANS_PLURAL=[
|
||||
'SECONDS',
|
||||
'MINUTES',
|
||||
'HOURS',
|
||||
'DAYS',
|
||||
'WEEKS',
|
||||
'MONTHS',
|
||||
'YEARS',
|
||||
'DECADES',
|
||||
'CENTURIES',
|
||||
'MILLENNIA'
|
||||
]
|
||||
|
||||
TIMESPANS_SINGULAR_RE = '|'.join(TIMESPANS)
|
||||
TIMESPANS_RE = '|'.join(TIMESPANS) + '|' + '|'.join(TIMESPANS_PLURAL)
|
||||
|
||||
MONTHS=[
|
||||
'JANUARY',
|
||||
'FEBRUARY',
|
||||
'MARCH',
|
||||
'APRIL',
|
||||
'MAY',
|
||||
'JUNE',
|
||||
'JULY',
|
||||
'AUGUST',
|
||||
'SEPTEMBER',
|
||||
'OCTOBER',
|
||||
'NOVEMBER',
|
||||
'DECEMBER',
|
||||
]
|
||||
|
||||
MONTHS_RE = '|'.join(MONTHS)
|
||||
|
||||
TIME_RE = (
|
||||
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
|
||||
+ '(?::(?P<minutes>[0-9]{2}))?'
|
||||
+ '(?::(?P<seconds>[0-9]{2}))?'
|
||||
+ ' ?(?P<ampm>AM|PM)?)'
|
||||
+ '|(?P<time_special>NOON|MIDNIGHT)'
|
||||
)
|
||||
|
||||
STRICT_TIME_RE = (
|
||||
'(?P<time_normal>(?P<hours>[0-9]{1,2})'
|
||||
+ '(?::(?P<minutes>[0-9]{2}))'
|
||||
+ '(?::(?P<seconds>[0-9]{2}))?'
|
||||
+ ' ?(?P<ampm>AM|PM)?)'
|
||||
+ '|(?P<time_special>NOON|MIDNIGHT)'
|
||||
)
|
||||
|
||||
RELATIVE_TIMESTAMP_RE = re.compile(
|
||||
'^('
|
||||
+ '(?P<immediate>YESTERDAY|TODAY|TOMORROW)'
|
||||
+ '|(?P<lastnext_group>'
|
||||
+ f'(?P<lastnext_weekday>{WEEKDAYS_RE})?'
|
||||
+ '(?P<lastnext>LAST |THIS |NEXT )?'
|
||||
+ '(?P<lastnext_target>'
|
||||
+ WEEKDAYS_RE
|
||||
+ '|' + TIMESPANS_SINGULAR_RE
|
||||
+ ')'
|
||||
+ ')|(?P<inago_group>'
|
||||
+ '(?P<in>IN )?'
|
||||
+ '(?P<inago_count>[0-9]+) '
|
||||
+ '(?P<inago_target>'
|
||||
+ TIMESPANS_RE
|
||||
+ ')'
|
||||
+ '(?P<ago> AGO)?'
|
||||
+ f') ?(?P<time_group>{TIME_RE})?'
|
||||
+ ')$'
|
||||
)
|
||||
|
||||
def days_per_month(month: str|int, year: int) -> int:
|
||||
if isinstance(month, int):
|
||||
month = MONTHS[month]
|
||||
|
||||
month = month.upper()
|
||||
|
||||
if month not in MONTHS:
|
||||
return 0
|
||||
|
||||
if month == 'FEBRUARY':
|
||||
return (
|
||||
29 if (year % 4 == 0) else 28
|
||||
)
|
||||
|
||||
if month in [
|
||||
'JANUARY',
|
||||
'MARCH',
|
||||
'MAY',
|
||||
'JULY',
|
||||
'AUGUST',
|
||||
'OCTOBER',
|
||||
'DECEMBER'
|
||||
]:
|
||||
return 31
|
||||
else:
|
||||
return 30
|
||||
|
||||
def try_parse_relative_timestamp(
|
||||
data: str,
|
||||
user_tz: ZoneInfo|timezone
|
||||
) -> Either[Exception, TAD]:
|
||||
dnorm = re.sub(
|
||||
'\\s+',
|
||||
' ',
|
||||
data # ensure any whitespace is reduced to single spaces
|
||||
).upper().strip().replace(',', '')
|
||||
|
||||
cur_dt = now().astimezone(user_tz)
|
||||
|
||||
err: Left[Exception, TAD] = Left(
|
||||
SyntaxError('Unable to parse relative time-string')
|
||||
)
|
||||
|
||||
m = re.match(
|
||||
RELATIVE_TIMESTAMP_RE,
|
||||
dnorm
|
||||
)
|
||||
|
||||
if not m:
|
||||
return err
|
||||
|
||||
time_to_use = time(23, 59, 59)
|
||||
|
||||
if m['time_group']:
|
||||
if m['time_normal']:
|
||||
hours = int(m['hours'])
|
||||
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||
if hours > 23 or minutes > 59 or seconds > 59:
|
||||
return err
|
||||
if m['ampm'] == 'AM' and hours > 12:
|
||||
return err
|
||||
if m['ampm'] == 'PM' and hours < 12:
|
||||
hours += 12
|
||||
if m['ampm'] == 'AM' and hours == 12:
|
||||
hours = 0
|
||||
time_to_use = time(hours, minutes, seconds)
|
||||
elif m['time_special']:
|
||||
match m['time_special']:
|
||||
case 'NOON':
|
||||
time_to_use = time(12, 00, 00)
|
||||
case 'MIDNIGHT':
|
||||
time_to_use = time(00, 00, 00)
|
||||
case _:
|
||||
pass
|
||||
|
||||
|
||||
def _from_date(dt: TAD, tz: ZoneInfo|timezone):
|
||||
adj = dt.astimezone(tz)
|
||||
return Right[Exception, datetime](datetime(
|
||||
adj.year,
|
||||
adj.month,
|
||||
adj.day,
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
0,
|
||||
tz
|
||||
))
|
||||
def _end_of_month(year: int, month: int, tz: ZoneInfo|timezone):
|
||||
return Right[Exception, datetime](datetime(
|
||||
year,
|
||||
month,
|
||||
days_per_month(MONTHS[month], year),
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
0,
|
||||
tz
|
||||
))
|
||||
|
||||
if m['immediate']:
|
||||
match m['immediate']:
|
||||
case 'YESTERDAY':
|
||||
yesterday = cur_dt - timedelta(days=1)
|
||||
return _from_date(yesterday, user_tz)
|
||||
case 'TODAY':
|
||||
return _from_date(cur_dt, user_tz)
|
||||
case 'TOMORROW':
|
||||
tomorrow = cur_dt + timedelta(days=1)
|
||||
return _from_date(tomorrow, user_tz)
|
||||
case _:
|
||||
return err
|
||||
|
||||
if m['lastnext_group']:
|
||||
target = m['lastnext_target']
|
||||
lastnext = m['lastnext']
|
||||
|
||||
if target in WEEKDAYS:
|
||||
tgt_weekday = WEEKDAYS.index(target)
|
||||
cur_weekday = cur_dt.weekday()
|
||||
|
||||
if m['lastnext_weekday']:
|
||||
# no, "tuesday next wednesday" is NOT a valid timestring.
|
||||
# you absolute BUFFOON.
|
||||
return err
|
||||
|
||||
if lastnext == 'LAST ':
|
||||
days_delta = cur_weekday - tgt_weekday
|
||||
if days_delta <= 0:
|
||||
# if days_delta == 0, that means e.g. today is wednesday and
|
||||
# the query is 'last wednesday', so we want the delta to be
|
||||
# -7 days in that case. if it's negative, that means e.g.
|
||||
# today is wednesday and the query is `last saturday`, so we
|
||||
# want to wrap around the weekdays.
|
||||
days_delta += 7
|
||||
days_delta *= -1
|
||||
elif lastnext == 'NEXT ':
|
||||
# "Next X" is kind of ambiguous. We default to the following:
|
||||
# - Advance to the next Sunday
|
||||
# - Advance to the next X
|
||||
# - If the delta is 3 days or less, (e.g. Next Monday on a
|
||||
# Friday), then add an extra week.
|
||||
to_next_week_boundary = 7 - cur_weekday
|
||||
days_delta = to_next_week_boundary + tgt_weekday
|
||||
if days_delta <= 3:
|
||||
days_delta += 7
|
||||
elif lastnext == 'THIS ':
|
||||
# "This X" means "the X of this week." can be in the future or
|
||||
# the past.
|
||||
days_delta = tgt_weekday - cur_weekday
|
||||
else:
|
||||
# Just "X" means "the soonest X after today."
|
||||
days_delta = tgt_weekday - cur_weekday
|
||||
if days_delta <= 0:
|
||||
days_delta += 7
|
||||
|
||||
return _from_date(
|
||||
cur_dt + timedelta(days=days_delta),
|
||||
user_tz
|
||||
)
|
||||
elif target in MONTHS:
|
||||
tgt_month = MONTHS.index(target) + 1 # datetime months are 1-indexed
|
||||
cur_month = cur_dt.month
|
||||
match lastnext:
|
||||
case 'LAST ':
|
||||
if cur_month > tgt_month:
|
||||
return _end_of_month(
|
||||
cur_dt.year,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
else:
|
||||
return _end_of_month(
|
||||
cur_dt.year-1,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
case 'THIS ':
|
||||
return _end_of_month(
|
||||
cur_dt.year,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
case 'NEXT ':
|
||||
return _end_of_month(
|
||||
cur_dt.year+1,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
case _:
|
||||
if cur_month > tgt_month:
|
||||
return _end_of_month(
|
||||
cur_dt.year+1,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
else:
|
||||
return _end_of_month(
|
||||
cur_dt.year,
|
||||
tgt_month,
|
||||
user_tz
|
||||
)
|
||||
|
||||
if (
|
||||
m['inago_group'] and
|
||||
(
|
||||
m['inago_target'] in TIMESPANS
|
||||
or m['inago_target'] in TIMESPANS_PLURAL
|
||||
)
|
||||
) or (
|
||||
m['lastnext_group'] and
|
||||
(
|
||||
m['lastnext_target'] in TIMESPANS
|
||||
or m['lastnext_target'] in TIMESPANS_PLURAL
|
||||
)
|
||||
):
|
||||
target = (
|
||||
(
|
||||
m['inago_target']
|
||||
) if m['inago_target'] in TIMESPANS else (
|
||||
# Singularize timespans before continuing
|
||||
TIMESPANS[TIMESPANS_PLURAL.index(m['inago_target'])]
|
||||
) if m['inago_target'] in TIMESPANS_PLURAL else (
|
||||
m['lastnext_target']
|
||||
) if m['lastnext_target'] in TIMESPANS else (
|
||||
TIMESPANS[TIMESPANS_PLURAL.index(m['lastnext_target'])]
|
||||
) if m['lastnext_target'] in TIMESPANS_PLURAL else (
|
||||
None
|
||||
)
|
||||
)
|
||||
|
||||
if not isinstance(target, str):
|
||||
return err
|
||||
|
||||
delta_unit = 0
|
||||
|
||||
if m['lastnext_group']:
|
||||
delta_unit = (
|
||||
1 if m['lastnext'] == 'NEXT ' else
|
||||
-1 if m['lastnext'] == 'LAST ' else
|
||||
0
|
||||
)
|
||||
elif m['inago_group']:
|
||||
try:
|
||||
delta_unit = (
|
||||
(-1 * int(m['inago_count'])) if m['ago']
|
||||
else int(m['inago_count'])
|
||||
)
|
||||
except Exception as e:
|
||||
return Left(e)
|
||||
|
||||
match target:
|
||||
case 'SECOND':
|
||||
return Right(cur_dt + timedelta(
|
||||
seconds=delta_unit
|
||||
))
|
||||
case 'MINUTE':
|
||||
return Right((cur_dt + timedelta(
|
||||
minutes=delta_unit
|
||||
)).replace(
|
||||
second=59
|
||||
))
|
||||
case 'HOUR':
|
||||
return Right((cur_dt + timedelta(
|
||||
hours=delta_unit
|
||||
)).replace(
|
||||
minute=59,
|
||||
second=59
|
||||
))
|
||||
case 'DAY':
|
||||
return _from_date((cur_dt + timedelta(
|
||||
days=delta_unit
|
||||
)), user_tz)
|
||||
case 'WEEK':
|
||||
# default "end of week" is friday.
|
||||
days_until_friday = WEEKDAYS.index('FRIDAY') - cur_dt.weekday()
|
||||
if days_until_friday < 0:
|
||||
days_until_friday += 7
|
||||
days_delta = days_until_friday + 7*delta_unit
|
||||
return _from_date(cur_dt + timedelta(
|
||||
days=days_delta
|
||||
), user_tz)
|
||||
case 'MONTH':
|
||||
final_month = ((cur_dt.month-1) + delta_unit) % 12 + 1
|
||||
final_year = cur_dt.year + ((cur_dt.month + delta_unit) // 12)
|
||||
return _from_date(cur_dt.replace(
|
||||
year=final_year,
|
||||
month=final_month,
|
||||
day=days_per_month(MONTHS[final_month-1], final_year)
|
||||
), user_tz)
|
||||
case 'YEAR':
|
||||
return _from_date(cur_dt.replace(
|
||||
year=cur_dt.year + delta_unit,
|
||||
month=12,
|
||||
day=31
|
||||
), user_tz)
|
||||
case 'DECADE':
|
||||
return _from_date(cur_dt.replace(
|
||||
year=cur_dt.year + 10*delta_unit,
|
||||
month=12,
|
||||
day=31
|
||||
), user_tz)
|
||||
case 'CENTURY':
|
||||
return _from_date(cur_dt.replace(
|
||||
year=cur_dt.year + 100*delta_unit,
|
||||
month=12,
|
||||
day=31
|
||||
), user_tz)
|
||||
case 'MILLENNIUM':
|
||||
return _from_date(cur_dt.replace(
|
||||
year=cur_dt.year + 1000*delta_unit,
|
||||
month=12,
|
||||
day=31
|
||||
), user_tz)
|
||||
case _:
|
||||
return err
|
||||
|
||||
return err
|
||||
|
||||
|
||||
ABSOLUTE_TIMESTAMP_RE = re.compile(
|
||||
'^'
|
||||
+ '(?P<year>[0-9][0-9]+)?'
|
||||
+ '(?:[-:\\/\\s]*(?P<month>[0-9]{1,2}))?'
|
||||
+ '(?:[-:\\/\\s]*(?P<day>[0-9]{1,2}))?'
|
||||
+ f' ?(?P<time_group>{TIME_RE})?'
|
||||
+ '$'
|
||||
)
|
||||
|
||||
JUST_TIME_RE = re.compile(
|
||||
f'^{STRICT_TIME_RE}$'
|
||||
)
|
||||
|
||||
def from_sophont_provided_data(data: str, user_tz: str = 'Etc/UTC') -> Either[Exception, TAD]:
|
||||
''' Try to parse a 'sophont-friendly' time-string to a timezone-aware
|
||||
datetime.
|
||||
|
||||
This function will process absolute timestamps ('2026-01-01 12:00') as
|
||||
well as relative ones ('next thursday 11:59PM'). Relative ones will be
|
||||
processed relative to the user's client-side current time, calculated as
|
||||
the server-side current time converted to the user's timezone.
|
||||
|
||||
If a time is not provided, the time will be assumed to be 11:59PM.
|
||||
|
||||
Note that user-local timezones will be converted to UTC.
|
||||
|
||||
>>> from_sophont_provided_data('thursday', 'Etc/UTC')
|
||||
Right[datetime](datetime(2025, 11, 27, 23, 59, 59, tzinfo=timezone.utc))
|
||||
|
||||
>>> from_sophont_provided_data('2026-01-01', 'America/Chicago')
|
||||
Right[datetime](datetime(2026, 01, 02, 05, 59, 59, tzinfo=timezone.utc))
|
||||
'''
|
||||
|
||||
try:
|
||||
tz = ZoneInfo(user_tz)
|
||||
except Exception as e:
|
||||
return Left(e)
|
||||
|
||||
err: Left[Exception, TAD] = Left(
|
||||
SyntaxError('Unable to parse absolute time-string.')
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
if m:=JUST_TIME_RE.match(data):
|
||||
# User input *just* a time.
|
||||
time_to_use = time(23, 59, 59)
|
||||
if m['time_normal']:
|
||||
hours = int(m['hours'])
|
||||
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||
if hours > 23 or minutes > 59 or seconds > 59:
|
||||
return err
|
||||
if m['ampm'] == 'AM' and hours > 12:
|
||||
return err
|
||||
if m['ampm'] == 'PM' and hours < 12:
|
||||
hours += 12
|
||||
if m['ampm'] == 'AM' and hours == 12:
|
||||
hours = 0
|
||||
time_to_use = time(hours, minutes, seconds)
|
||||
elif m['time_special']:
|
||||
match m['time_special']:
|
||||
case 'NOON':
|
||||
time_to_use = time(12, 00, 00)
|
||||
case 'MIDNIGHT':
|
||||
time_to_use = time(00, 00, 00)
|
||||
case _:
|
||||
return err
|
||||
|
||||
return Right(datetime.now(tz).replace(
|
||||
hour=time_to_use.hour,
|
||||
minute=time_to_use.minute,
|
||||
second=time_to_use.second
|
||||
))
|
||||
|
||||
if not (m:=ABSOLUTE_TIMESTAMP_RE.match(data)):
|
||||
return try_parse_relative_timestamp(
|
||||
data,
|
||||
tz
|
||||
)
|
||||
|
||||
time_to_use = time(23, 59, 59)
|
||||
|
||||
if m['time_group']:
|
||||
if m['time_normal']:
|
||||
hours = int(m['hours'])
|
||||
minutes = int(m['minutes']) if m['minutes'] else 59
|
||||
seconds = int(m['seconds']) if m['seconds'] else 59
|
||||
if hours > 23 or minutes > 59 or seconds > 59:
|
||||
return err
|
||||
if m['ampm'] == 'AM' and hours > 12:
|
||||
return err
|
||||
if m['ampm'] == 'PM' and hours < 12:
|
||||
hours += 12
|
||||
if m['ampm'] == 'AM' and hours == 12:
|
||||
hours = 0
|
||||
time_to_use = time(hours, minutes, seconds)
|
||||
elif m['time_special']:
|
||||
match m['time_special']:
|
||||
case 'NOON':
|
||||
time_to_use = time(12, 00, 00)
|
||||
case 'MIDNIGHT':
|
||||
time_to_use = time(00, 00, 00)
|
||||
case _:
|
||||
pass
|
||||
|
||||
year = int(m['year']) if m['year'] else None
|
||||
year_raw = year
|
||||
if year is not None and year < 100:
|
||||
year += (datetime.now(tz).year // 100)*100 # handle 2-digit years
|
||||
month = int(m['month']) if m['month'] else None
|
||||
day = int(m['day']) if m['day'] else None
|
||||
|
||||
if year and month and day:
|
||||
return Right(datetime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
tzinfo=tz
|
||||
))
|
||||
|
||||
if year and month:
|
||||
if year_raw is not None and year_raw < 12:
|
||||
# User input something like "10/25".
|
||||
# Assume that's actually a month and a day, not a year and a month.
|
||||
cur = datetime.now(tz)
|
||||
target = datetime(
|
||||
cur.year,
|
||||
year_raw,
|
||||
month,
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
tzinfo=tz
|
||||
)
|
||||
|
||||
if target > cur:
|
||||
# that month and date this year is in the future.
|
||||
return Right(target)
|
||||
else:
|
||||
# that month and date this year would be in the past.
|
||||
# assume they actually want it due on that date *next* year.
|
||||
return Right(target.replace(
|
||||
year=cur.year+1
|
||||
))
|
||||
else:
|
||||
# User input something like "26/11".
|
||||
# This is probably *actually* a year and a month
|
||||
return Right(
|
||||
datetime(
|
||||
year,
|
||||
month,
|
||||
days_per_month(MONTHS[month-1], year),
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
tzinfo=tz
|
||||
)
|
||||
)
|
||||
|
||||
if month and day:
|
||||
cur = datetime.now(tz)
|
||||
target = datetime(
|
||||
cur.year,
|
||||
month,
|
||||
day,
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
tzinfo=tz
|
||||
)
|
||||
|
||||
if target > cur:
|
||||
# that month and date this year is in the future.
|
||||
return Right(target)
|
||||
else:
|
||||
# that month and date this year would be in the past.
|
||||
# assume they actually want it due on that date *next* year.
|
||||
return Right(target.replace(
|
||||
year=cur.year+1
|
||||
))
|
||||
|
||||
if year:
|
||||
return Right(
|
||||
datetime(
|
||||
year,
|
||||
12,
|
||||
31,
|
||||
time_to_use.hour,
|
||||
time_to_use.minute,
|
||||
time_to_use.second,
|
||||
tzinfo=tz
|
||||
)
|
||||
)
|
||||
|
||||
# User input something bizarre. See if parse_relative() can do anything with it.
|
||||
return try_parse_relative_timestamp(data, tz)
|
||||
except Exception as e:
|
||||
log.error(f'An internal error occured while parsing the time-string {data}: {e!s}')
|
||||
return Left(e)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from secrets import token_hex
|
||||
from flask import Blueprint, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType]
|
||||
|
|
@ -20,6 +20,7 @@ from taskflower.sanitize.code import SignUpCodeForUser, ZoneInviteCodeForUser
|
|||
from taskflower.types import assert_usr
|
||||
from taskflower.types.either import Either, Left, Right, gather_successes
|
||||
from taskflower.types.option import Option
|
||||
from taskflower.util.time import now
|
||||
from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception
|
||||
from taskflower.web.utils.request import get_next
|
||||
|
||||
|
|
@ -124,8 +125,8 @@ def new_sign_up():
|
|||
try:
|
||||
code = SignUpCode(
|
||||
code=token_hex(16), # pyright:ignore[reportCallIssue]
|
||||
created=datetime.now(), # pyright:ignore[reportCallIssue]
|
||||
expires=datetime.now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
|
||||
created=now(), # pyright:ignore[reportCallIssue]
|
||||
expires=now() + timedelta(weeks=1), # pyright:ignore[reportCallIssue]
|
||||
created_by=cur_usr.id # pyright:ignore[reportCallIssue]
|
||||
)
|
||||
db.session.add(code)
|
||||
|
|
@ -240,7 +241,7 @@ def enter():
|
|||
)
|
||||
).flat_map(
|
||||
lambda code: Either.do_assert(
|
||||
code.expires > datetime.now(),
|
||||
code.expires > now(),
|
||||
'Code expiration date is in the future'
|
||||
).map(
|
||||
lambda _: code
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue