moosey-cms 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moosey_cms/.python-version +1 -0
- moosey_cms/README.md +0 -0
- moosey_cms/__init__.py +12 -0
- moosey_cms/cache.py +68 -0
- moosey_cms/file_watcher.py +31 -0
- moosey_cms/filters.py +522 -0
- moosey_cms/helpers.py +256 -0
- moosey_cms/hot_reload_script.py +91 -0
- moosey_cms/main.py +281 -0
- moosey_cms/md.py +192 -0
- moosey_cms/models.py +79 -0
- moosey_cms/py.typed +0 -0
- moosey_cms/pyproject.toml +28 -0
- moosey_cms/seo.py +155 -0
- moosey_cms/static/js/reload-script.js +77 -0
- moosey_cms-0.1.0.dist-info/METADATA +370 -0
- moosey_cms-0.1.0.dist-info/RECORD +18 -0
- moosey_cms-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
moosey_cms/README.md
ADDED
|
File without changes
|
moosey_cms/__init__.py
ADDED
moosey_cms/cache.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2026 Anthony Mugendi
|
|
3
|
+
This software is released under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cachetools import TTLCache, cached
|
|
7
|
+
from cachetools.keys import hashkey
|
|
8
|
+
from functools import wraps
|
|
9
|
+
|
|
10
|
+
# Cache with TTL of 30 days
|
|
11
|
+
cache = TTLCache(maxsize=1000, ttl=3600 * 24 * 30)
|
|
12
|
+
|
|
13
|
+
def clear_cache():
|
|
14
|
+
cache.clear()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# clear once even for multiple filess
|
|
18
|
+
@cached(TTLCache(maxsize=100, ttl=5))
|
|
19
|
+
def clear_cache_on_file_change(file_path, event_type):
|
|
20
|
+
# print(f"File {file_path} changed.")
|
|
21
|
+
clear_cache()
|
|
22
|
+
|
|
23
|
+
# --- HELPER: Convert mutable types to immutable (hashable) ---
|
|
24
|
+
def make_hashable(value):
|
|
25
|
+
"""
|
|
26
|
+
Recursively converts dictionaries to sorted tuples of items,
|
|
27
|
+
and lists to tuples. This allows them to be hashed for caching.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return tuple(sorted((k, make_hashable(v)) for k, v in value.items()))
|
|
31
|
+
elif isinstance(value, (list, set)):
|
|
32
|
+
return tuple(make_hashable(v) for v in value)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
def cache_fn(cache=cache, debug=True, exclude_args=None):
|
|
36
|
+
"""
|
|
37
|
+
exclude_args: list of argument indices or keyword names to ignore
|
|
38
|
+
"""
|
|
39
|
+
if exclude_args is None:
|
|
40
|
+
exclude_args = ['templates']
|
|
41
|
+
|
|
42
|
+
def decorator(func):
|
|
43
|
+
@wraps(func)
|
|
44
|
+
def wrapper(*args, **kwargs):
|
|
45
|
+
# Filter args for hashing
|
|
46
|
+
args_to_hash = tuple(
|
|
47
|
+
make_hashable(a) for i, a in enumerate(args)
|
|
48
|
+
if i not in exclude_args
|
|
49
|
+
)
|
|
50
|
+
# Filter kwargs for hashing (e.g. ignore 'templates')
|
|
51
|
+
kwargs_to_hash = {
|
|
52
|
+
k: make_hashable(v) for k, v in kwargs.items()
|
|
53
|
+
if k not in exclude_args
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
key = hashkey(*args_to_hash, **kwargs_to_hash)
|
|
57
|
+
|
|
58
|
+
if key in cache:
|
|
59
|
+
if debug:
|
|
60
|
+
print(' '*4, f'> Cache Hit For: "{func.__name__}"')
|
|
61
|
+
return cache[key]
|
|
62
|
+
|
|
63
|
+
result = func(*args, **kwargs)
|
|
64
|
+
cache[key] = result
|
|
65
|
+
return result
|
|
66
|
+
return wrapper
|
|
67
|
+
return decorator
|
|
68
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from watchdog.observers import Observer
|
|
2
|
+
from watchdog.events import FileSystemEventHandler
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FileChangeHandler(FileSystemEventHandler):
|
|
6
|
+
def __init__(self, callback):
|
|
7
|
+
self.callback = callback
|
|
8
|
+
self.ts =0
|
|
9
|
+
|
|
10
|
+
def on_any_event(self, event):
|
|
11
|
+
if event.is_directory:
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
if event.event_type == "modified" or event.event_type == "created":
|
|
15
|
+
# Trigger the callback
|
|
16
|
+
self.callback(event.src_path, event.event_type)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def start_watching(path, callback):
|
|
20
|
+
"""
|
|
21
|
+
Starts the watcher in a background thread and returns immediately.
|
|
22
|
+
"""
|
|
23
|
+
event_handler = FileChangeHandler(callback)
|
|
24
|
+
observer = Observer()
|
|
25
|
+
observer.schedule(event_handler, path, recursive=True)
|
|
26
|
+
|
|
27
|
+
# This spawns a new thread, so it won't block your main code
|
|
28
|
+
observer.start()
|
|
29
|
+
|
|
30
|
+
# Return the observer so you can stop it nicely on server shutdown
|
|
31
|
+
return observer
|
moosey_cms/filters.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jinja2 template filters for content management.
|
|
3
|
+
Usage: Import and register with Jinja2Templates environment.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
import re
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from .seo import seo_tags
|
|
12
|
+
|
|
13
|
+
# ============================================================================
|
|
14
|
+
# DATE & TIME FILTERS
|
|
15
|
+
# ============================================================================
|
|
16
|
+
|
|
17
|
+
def fancy_date(dt):
|
|
18
|
+
"""Format date as '13th Jan, 2026 at 6:00 PM'"""
|
|
19
|
+
if not dt:
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
day = dt.day
|
|
23
|
+
if 10 <= day % 100 <= 20:
|
|
24
|
+
suffix = 'th'
|
|
25
|
+
else:
|
|
26
|
+
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
|
|
27
|
+
|
|
28
|
+
formatted = dt.strftime(f'%d{suffix} %b, %Y at %I:%M %p')
|
|
29
|
+
# Remove leading zero from hour if present
|
|
30
|
+
parts = formatted.split('at ')
|
|
31
|
+
if len(parts) == 2 and parts[1][0] == '0':
|
|
32
|
+
formatted = parts[0] + 'at ' + parts[1][1:]
|
|
33
|
+
return formatted
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def short_date(dt):
|
|
37
|
+
"""Format date as 'Jan 13, 2026'"""
|
|
38
|
+
if not dt:
|
|
39
|
+
return ""
|
|
40
|
+
return dt.strftime('%b %d, %Y')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def iso_date(dt):
|
|
44
|
+
"""Format date as '2026-01-13'"""
|
|
45
|
+
if not dt:
|
|
46
|
+
return ""
|
|
47
|
+
return dt.strftime('%Y-%m-%d')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def relative_time(dt):
|
|
51
|
+
"""Format date as relative time (e.g., '2 hours ago', 'yesterday')"""
|
|
52
|
+
if not dt:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
now = datetime.now()
|
|
56
|
+
diff = now - dt
|
|
57
|
+
|
|
58
|
+
seconds = diff.total_seconds()
|
|
59
|
+
|
|
60
|
+
if seconds < 60:
|
|
61
|
+
return "just now"
|
|
62
|
+
elif seconds < 3600:
|
|
63
|
+
minutes = int(seconds / 60)
|
|
64
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
|
65
|
+
elif seconds < 86400:
|
|
66
|
+
hours = int(seconds / 3600)
|
|
67
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
68
|
+
elif seconds < 172800:
|
|
69
|
+
return "yesterday"
|
|
70
|
+
elif seconds < 604800:
|
|
71
|
+
days = int(seconds / 86400)
|
|
72
|
+
return f"{days} days ago"
|
|
73
|
+
elif seconds < 2592000:
|
|
74
|
+
weeks = int(seconds / 604800)
|
|
75
|
+
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
|
|
76
|
+
elif seconds < 31536000:
|
|
77
|
+
months = int(seconds / 2592000)
|
|
78
|
+
return f"{months} month{'s' if months != 1 else ''} ago"
|
|
79
|
+
else:
|
|
80
|
+
years = int(seconds / 31536000)
|
|
81
|
+
return f"{years} year{'s' if years != 1 else ''} ago"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def time_only(dt):
|
|
85
|
+
"""Format as time only '6:00 PM'"""
|
|
86
|
+
if not dt:
|
|
87
|
+
return ""
|
|
88
|
+
formatted = dt.strftime('%I:%M %p')
|
|
89
|
+
if formatted[0] == '0':
|
|
90
|
+
formatted = formatted[1:]
|
|
91
|
+
return formatted
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ============================================================================
|
|
95
|
+
# CURRENCY FILTERS
|
|
96
|
+
# ============================================================================
|
|
97
|
+
|
|
98
|
+
def currency(value, code='USD', symbol='$'):
|
|
99
|
+
"""Format number as currency '$1,234.56' using pycountry for currency info"""
|
|
100
|
+
if value is None:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
value = float(value)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
import pycountry
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
currency_obj = pycountry.currencies.get(alpha_3=code.upper())
|
|
111
|
+
decimals = 0 if currency_obj and int(currency_obj.numeric) == 392 else 2
|
|
112
|
+
except (LookupError, AttributeError):
|
|
113
|
+
decimals = 2
|
|
114
|
+
except ImportError:
|
|
115
|
+
decimals = 0 if code.upper() == 'JPY' else 2
|
|
116
|
+
|
|
117
|
+
symbols = {
|
|
118
|
+
'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥',
|
|
119
|
+
'CNY': '¥', 'INR': '₹', 'KES': 'KSh', 'NGN': '₦',
|
|
120
|
+
'ZAR': 'R', 'AUD': 'A$', 'CAD': 'C$', 'CHF': 'Fr',
|
|
121
|
+
'BRL': 'R$', 'MXN': '$', 'RUB': '₽', 'TRY': '₺',
|
|
122
|
+
'SEK': 'kr', 'NOK': 'kr', 'DKK': 'kr', 'PLN': 'zł',
|
|
123
|
+
'AED': 'د.إ', 'SAR': 'ر.س', 'EGP': 'E£', 'THB': '฿',
|
|
124
|
+
'SGD': 'S$', 'HKD': 'HK$', 'KRW': '₩', 'IDR': 'Rp',
|
|
125
|
+
'PHP': '₱', 'VND': '₫', 'MYR': 'RM', 'PKR': '₨',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
symbol = symbols.get(code.upper(), symbol)
|
|
129
|
+
|
|
130
|
+
if decimals == 0:
|
|
131
|
+
formatted = f"{int(value):,}"
|
|
132
|
+
else:
|
|
133
|
+
formatted = f"{value:,.{decimals}f}"
|
|
134
|
+
|
|
135
|
+
return f"{symbol}{formatted}"
|
|
136
|
+
except (ValueError, TypeError):
|
|
137
|
+
return str(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compact_currency(value, code='USD'):
|
|
141
|
+
"""Format large numbers compactly '$1.2M', '$45K'"""
|
|
142
|
+
if value is None:
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
value = float(value)
|
|
147
|
+
|
|
148
|
+
symbols = {
|
|
149
|
+
'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥',
|
|
150
|
+
'CNY': '¥', 'INR': '₹', 'KES': 'KSh', 'NGN': '₦',
|
|
151
|
+
'ZAR': 'R', 'AUD': 'A$', 'CAD': 'C$', 'CHF': 'Fr'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
symbol = symbols.get(code.upper(), '$')
|
|
155
|
+
|
|
156
|
+
if value >= 1_000_000_000:
|
|
157
|
+
return f"{symbol}{value/1_000_000_000:.1f}B"
|
|
158
|
+
elif value >= 1_000_000:
|
|
159
|
+
return f"{symbol}{value/1_000_000:.1f}M"
|
|
160
|
+
elif value >= 1_000:
|
|
161
|
+
return f"{symbol}{value/1_000:.1f}K"
|
|
162
|
+
else:
|
|
163
|
+
return f"{symbol}{value:.2f}"
|
|
164
|
+
except (ValueError, TypeError):
|
|
165
|
+
return str(value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ============================================================================
|
|
169
|
+
# COUNTRY & LOCALE FILTERS
|
|
170
|
+
# ============================================================================
|
|
171
|
+
|
|
172
|
+
def country_flag(country_code):
|
|
173
|
+
"""Convert ISO 3166-1 alpha-2 or alpha-3 country code to emoji flag"""
|
|
174
|
+
if not country_code:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
import pycountry
|
|
179
|
+
|
|
180
|
+
country_code = country_code.strip().upper()
|
|
181
|
+
|
|
182
|
+
if len(country_code) == 2:
|
|
183
|
+
alpha_2 = country_code
|
|
184
|
+
elif len(country_code) == 3:
|
|
185
|
+
country = pycountry.countries.get(alpha_3=country_code)
|
|
186
|
+
alpha_2 = country.alpha_2 if country else None
|
|
187
|
+
else:
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
if alpha_2 and len(alpha_2) == 2:
|
|
191
|
+
return ''.join(chr(ord(c) + 127397) for c in alpha_2)
|
|
192
|
+
|
|
193
|
+
return ""
|
|
194
|
+
except (ImportError, LookupError, AttributeError):
|
|
195
|
+
if len(country_code) == 2:
|
|
196
|
+
country_code = country_code.upper()
|
|
197
|
+
return ''.join(chr(ord(c) + 127397) for c in country_code)
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def country_name(country_code):
|
|
202
|
+
"""Convert country code (alpha-2 or alpha-3) to full name using pycountry"""
|
|
203
|
+
if not country_code:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
import pycountry
|
|
208
|
+
|
|
209
|
+
country_code = country_code.strip().upper()
|
|
210
|
+
|
|
211
|
+
if len(country_code) == 2:
|
|
212
|
+
country = pycountry.countries.get(alpha_2=country_code)
|
|
213
|
+
elif len(country_code) == 3:
|
|
214
|
+
country = pycountry.countries.get(alpha_3=country_code)
|
|
215
|
+
else:
|
|
216
|
+
results = pycountry.countries.search_fuzzy(country_code)
|
|
217
|
+
country = results[0] if results else None
|
|
218
|
+
|
|
219
|
+
return country.name if country else country_code
|
|
220
|
+
except ImportError:
|
|
221
|
+
fallback = {
|
|
222
|
+
'US': 'United States', 'GB': 'United Kingdom', 'CA': 'Canada',
|
|
223
|
+
'AU': 'Australia', 'DE': 'Germany', 'FR': 'France', 'IT': 'Italy',
|
|
224
|
+
'ES': 'Spain', 'JP': 'Japan', 'CN': 'China', 'IN': 'India',
|
|
225
|
+
'BR': 'Brazil', 'MX': 'Mexico', 'KE': 'Kenya', 'NG': 'Nigeria',
|
|
226
|
+
'ZA': 'South Africa', 'EG': 'Egypt', 'GH': 'Ghana', 'TZ': 'Tanzania',
|
|
227
|
+
}
|
|
228
|
+
return fallback.get(country_code.upper(), country_code)
|
|
229
|
+
except (LookupError, AttributeError):
|
|
230
|
+
return country_code
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def language_name(language_code):
|
|
234
|
+
"""Convert language code (alpha-2 or alpha-3) to full name using pycountry"""
|
|
235
|
+
if not language_code:
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
import pycountry
|
|
240
|
+
|
|
241
|
+
language_code = language_code.strip().lower()
|
|
242
|
+
|
|
243
|
+
if len(language_code) == 2:
|
|
244
|
+
language = pycountry.languages.get(alpha_2=language_code)
|
|
245
|
+
elif len(language_code) == 3:
|
|
246
|
+
language = pycountry.languages.get(alpha_3=language_code)
|
|
247
|
+
if not language:
|
|
248
|
+
language = pycountry.languages.get(bibliographic=language_code)
|
|
249
|
+
else:
|
|
250
|
+
results = pycountry.languages.search_fuzzy(language_code)
|
|
251
|
+
language = results[0] if results else None
|
|
252
|
+
|
|
253
|
+
return language.name if language else language_code
|
|
254
|
+
except ImportError:
|
|
255
|
+
fallback = {
|
|
256
|
+
'en': 'English', 'es': 'Spanish', 'fr': 'French', 'de': 'German',
|
|
257
|
+
'it': 'Italian', 'pt': 'Portuguese', 'ru': 'Russian', 'ja': 'Japanese',
|
|
258
|
+
'zh': 'Chinese', 'ar': 'Arabic', 'hi': 'Hindi', 'sw': 'Swahili',
|
|
259
|
+
}
|
|
260
|
+
return fallback.get(language_code.lower(), language_code)
|
|
261
|
+
except (LookupError, AttributeError):
|
|
262
|
+
return language_code
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def currency_name(currency_code):
|
|
266
|
+
"""Convert currency code to full name using pycountry"""
|
|
267
|
+
if not currency_code:
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
import pycountry
|
|
272
|
+
|
|
273
|
+
currency = pycountry.currencies.get(alpha_3=currency_code.upper())
|
|
274
|
+
return currency.name if currency else currency_code.upper()
|
|
275
|
+
except ImportError:
|
|
276
|
+
fallback = {
|
|
277
|
+
'USD': 'US Dollar', 'EUR': 'Euro', 'GBP': 'Pound Sterling',
|
|
278
|
+
'JPY': 'Yen', 'CNY': 'Yuan Renminbi', 'INR': 'Indian Rupee',
|
|
279
|
+
'KES': 'Kenyan Shilling', 'NGN': 'Naira', 'ZAR': 'Rand',
|
|
280
|
+
}
|
|
281
|
+
return fallback.get(currency_code.upper(), currency_code.upper())
|
|
282
|
+
except (LookupError, AttributeError):
|
|
283
|
+
return currency_code.upper()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ============================================================================
|
|
287
|
+
# TEXT FORMATTING FILTERS
|
|
288
|
+
# ============================================================================
|
|
289
|
+
|
|
290
|
+
def truncate_words(text, count=50, suffix='...'):
|
|
291
|
+
"""Truncate text to specified word count"""
|
|
292
|
+
if not text:
|
|
293
|
+
return ""
|
|
294
|
+
|
|
295
|
+
words = text.split()
|
|
296
|
+
if len(words) <= count:
|
|
297
|
+
return text
|
|
298
|
+
|
|
299
|
+
return ' '.join(words[:count]) + suffix
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def reading_time(text, wpm=200):
|
|
303
|
+
"""Calculate reading time in minutes"""
|
|
304
|
+
if not text:
|
|
305
|
+
return "0 min read"
|
|
306
|
+
|
|
307
|
+
word_count = len(text.split())
|
|
308
|
+
minutes = max(1, round(word_count / wpm))
|
|
309
|
+
|
|
310
|
+
return f"{minutes} min read"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def slugify(text):
|
|
314
|
+
"""Convert text to URL-friendly slug"""
|
|
315
|
+
if not text:
|
|
316
|
+
return ""
|
|
317
|
+
|
|
318
|
+
text = text.lower()
|
|
319
|
+
text = re.sub(r'[^\w\s-]', '', text)
|
|
320
|
+
text = re.sub(r'[-\s]+', '-', text)
|
|
321
|
+
return text.strip('-')
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def title_case(text):
|
|
325
|
+
"""Convert to title case, preserving acronyms"""
|
|
326
|
+
if not text:
|
|
327
|
+
return ""
|
|
328
|
+
|
|
329
|
+
small_words = {'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for',
|
|
330
|
+
'in', 'of', 'on', 'or', 'the', 'to', 'up', 'via'}
|
|
331
|
+
|
|
332
|
+
words = text.split()
|
|
333
|
+
result = []
|
|
334
|
+
|
|
335
|
+
for i, word in enumerate(words):
|
|
336
|
+
if i == 0 or i == len(words) - 1:
|
|
337
|
+
result.append(word.capitalize())
|
|
338
|
+
elif word.isupper() and len(word) > 1:
|
|
339
|
+
result.append(word)
|
|
340
|
+
elif word.lower() in small_words:
|
|
341
|
+
result.append(word.lower())
|
|
342
|
+
else:
|
|
343
|
+
result.append(word.capitalize())
|
|
344
|
+
|
|
345
|
+
return ' '.join(result)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def excerpt(text, length=150, suffix='...'):
|
|
349
|
+
"""Create excerpt from text, breaking at sentence"""
|
|
350
|
+
if not text or len(text) <= length:
|
|
351
|
+
return text
|
|
352
|
+
|
|
353
|
+
truncated = text[:length]
|
|
354
|
+
last_period = truncated.rfind('.')
|
|
355
|
+
last_question = truncated.rfind('?')
|
|
356
|
+
last_exclamation = truncated.rfind('!')
|
|
357
|
+
|
|
358
|
+
break_point = max(last_period, last_question, last_exclamation)
|
|
359
|
+
|
|
360
|
+
if break_point > length * 0.6:
|
|
361
|
+
return text[:break_point + 1]
|
|
362
|
+
else:
|
|
363
|
+
last_space = truncated.rfind(' ')
|
|
364
|
+
if last_space > 0:
|
|
365
|
+
return truncated[:last_space] + suffix
|
|
366
|
+
return truncated + suffix
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def smart_quotes(text):
|
|
370
|
+
"""Convert straight quotes to smart/curly quotes"""
|
|
371
|
+
if not text:
|
|
372
|
+
return ""
|
|
373
|
+
|
|
374
|
+
text = re.sub(r'(\s|^)"', '\u201c', text)
|
|
375
|
+
text = re.sub(r'"(\s|$|[,.;:!?])', '\u201d', text)
|
|
376
|
+
text = re.sub(r"(\s|^)'", '\u2018', text)
|
|
377
|
+
text = re.sub(r"'(\s|$|[,.;:!?])", '\u2019', text)
|
|
378
|
+
|
|
379
|
+
return text
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ============================================================================
|
|
383
|
+
# NUMBER FORMATTING FILTERS
|
|
384
|
+
# ============================================================================
|
|
385
|
+
|
|
386
|
+
def number_format(value, decimals=0):
|
|
387
|
+
"""Format number with thousand separators"""
|
|
388
|
+
if value is None:
|
|
389
|
+
return ""
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
value = float(value)
|
|
393
|
+
if decimals == 0:
|
|
394
|
+
return f"{int(value):,}"
|
|
395
|
+
else:
|
|
396
|
+
return f"{value:,.{decimals}f}"
|
|
397
|
+
except (ValueError, TypeError):
|
|
398
|
+
return str(value)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def percentage(value, decimals=1):
|
|
402
|
+
"""Format as percentage"""
|
|
403
|
+
if value is None:
|
|
404
|
+
return ""
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
value = float(value)
|
|
408
|
+
return f"{value:.{decimals}f}%"
|
|
409
|
+
except (ValueError, TypeError):
|
|
410
|
+
return str(value)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def ordinal(value):
|
|
414
|
+
"""Convert number to ordinal (1st, 2nd, 3rd)"""
|
|
415
|
+
if value is None:
|
|
416
|
+
return ""
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
value = int(value)
|
|
420
|
+
if 10 <= value % 100 <= 20:
|
|
421
|
+
suffix = 'th'
|
|
422
|
+
else:
|
|
423
|
+
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(value % 10, 'th')
|
|
424
|
+
return f"{value}{suffix}"
|
|
425
|
+
except (ValueError, TypeError):
|
|
426
|
+
return str(value)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ============================================================================
|
|
430
|
+
# FILE SIZE FILTERS
|
|
431
|
+
# ============================================================================
|
|
432
|
+
|
|
433
|
+
def filesize(bytes_value):
|
|
434
|
+
"""Format bytes as human-readable file size"""
|
|
435
|
+
if bytes_value is None:
|
|
436
|
+
return ""
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
bytes_value = float(bytes_value)
|
|
440
|
+
|
|
441
|
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
442
|
+
if bytes_value < 1024.0:
|
|
443
|
+
return f"{bytes_value:.1f} {unit}"
|
|
444
|
+
bytes_value /= 1024.0
|
|
445
|
+
|
|
446
|
+
return f"{bytes_value:.1f} PB"
|
|
447
|
+
except (ValueError, TypeError):
|
|
448
|
+
return str(bytes_value)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ============================================================================
|
|
452
|
+
# UTILITY FILTERS
|
|
453
|
+
# ============================================================================
|
|
454
|
+
|
|
455
|
+
def default_if_none(value, default=""):
|
|
456
|
+
"""Return default value if None"""
|
|
457
|
+
return default if value is None else value
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def yesno(value, yes="Yes", no="No"):
|
|
461
|
+
"""Convert boolean to yes/no text"""
|
|
462
|
+
return yes if value else no
|
|
463
|
+
|
|
464
|
+
def read_time(text: str) -> str:
|
|
465
|
+
if not text:
|
|
466
|
+
return "0 min read"
|
|
467
|
+
|
|
468
|
+
word_count = len(text.split())
|
|
469
|
+
# Average reading speed is 200 wpm
|
|
470
|
+
minutes = math.ceil(word_count / 200)
|
|
471
|
+
if minutes <= 1:
|
|
472
|
+
return "1 min read"
|
|
473
|
+
return f"{minutes} min read"
|
|
474
|
+
|
|
475
|
+
# ============================================================================
|
|
476
|
+
# REGISTRATION FUNCTION
|
|
477
|
+
# ============================================================================
|
|
478
|
+
|
|
479
|
+
def register_filters(jinja_env):
|
|
480
|
+
"""
|
|
481
|
+
Register all filters with a Jinja2 environment.
|
|
482
|
+
|
|
483
|
+
Usage:
|
|
484
|
+
from fastapi.templating import Jinja2Templates
|
|
485
|
+
from . import filters
|
|
486
|
+
|
|
487
|
+
templates = Jinja2Templates(directory="templates")
|
|
488
|
+
filters.register_filters(templates.env)
|
|
489
|
+
"""
|
|
490
|
+
filters_dict = {
|
|
491
|
+
'fancy_date': fancy_date,
|
|
492
|
+
'short_date': short_date,
|
|
493
|
+
'iso_date': iso_date,
|
|
494
|
+
'relative_time': relative_time,
|
|
495
|
+
'time_only': time_only,
|
|
496
|
+
'currency': currency,
|
|
497
|
+
'compact_currency': compact_currency,
|
|
498
|
+
'country_flag': country_flag,
|
|
499
|
+
'country_name': country_name,
|
|
500
|
+
'language_name': language_name,
|
|
501
|
+
'currency_name': currency_name,
|
|
502
|
+
'truncate_words': truncate_words,
|
|
503
|
+
'reading_time': reading_time,
|
|
504
|
+
'slugify': slugify,
|
|
505
|
+
'title_case': title_case,
|
|
506
|
+
'excerpt': excerpt,
|
|
507
|
+
'smart_quotes': smart_quotes,
|
|
508
|
+
'number_format': number_format,
|
|
509
|
+
'percentage': percentage,
|
|
510
|
+
'ordinal': ordinal,
|
|
511
|
+
'filesize': filesize,
|
|
512
|
+
'default_if_none': default_if_none,
|
|
513
|
+
'yesno': yesno,
|
|
514
|
+
'read_time':read_time
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for name, func in filters_dict.items():
|
|
518
|
+
jinja_env.filters[name] = func
|
|
519
|
+
|
|
520
|
+
jinja_env.globals['seo'] = seo_tags
|
|
521
|
+
|
|
522
|
+
return jinja_env
|