Skip to content

Commit 22f350a

Browse files
authored
Merge pull request #71 from Roznoshchik/preact-app-frontend
Tags Management & SQLAlchemy 2.0 Cleanup This PR adds a complete Tags management UI and modernizes the SQLAlchemy relationship patterns across the codebase. Features Tags Management UI - New Tags page with list view, filters, search, and pagination - TagCard component with color indicator and article/highlight counts - TagEditModal with rename, archive toggle, and delete functionality - Inline tag creation from Combobox dropdown in all modals Combobox Enhancements - onCreate prop for inline tag creation - "Create [query]" option appears when no exact match exists - Escape key closes dropdown only (not parent modal) - Fixed spacebar selecting instead of typing QuillEditor - Added markdown paste support - Added H1-H6 header formatting options Backend Improvements SQLAlchemy 2.0 Patterns - Removed lazy="dynamic" from Article.tags, Article.highlights, Highlight.tags, and Tag relationships - Added selectinload with archived tag filtering to eliminate N+1 queries - Standardized to_dict pattern: id = integer PK, uuid = string UUID Data Model Consolidation - Merged Topic into Tag with dual-write support - Added backfill migration to populate Tags from existing Topics - Standardized API routes to use UUID (articles, highlights, tags) Tags API - POST /tags now unarchives existing archived tags instead of failing - Improved query performance with proper eager loading Other Changes - Toggle switch CSS class for checkbox inputs - Icon component passes through onClick and other props - Removed legacy Topic-related tests - Code formatting with Biome
2 parents 305f7f9 + 03f1b10 commit 22f350a

54 files changed

Lines changed: 1802 additions & 632 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ they shouldn't include random comments or the micro details of every file change
231231
## Working state
232232
If I say status task - <TASK NAME>
233233

234-
Explore the .local file for files of the following format
234+
Explore the .claude folder for files of the following format
235235
Check the task name or if no task name then the branch name
236236
then check
237237
claude-progress-<branch name>.md

app/api/articles.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sqlalchemy as sa
2+
from sqlalchemy.orm import selectinload
23

34
from app import db, CustomLogger
45
from app.api import bp
@@ -12,7 +13,7 @@
1213
process_url_entry,
1314
process_file_upload,
1415
)
15-
from app.models import Article, Event
16+
from app.models import Article, Event, Tag
1617
from app.models.event import EventName
1718
from app.api.errors import bad_request, error_response
1819
from app.api.helpers.update_tags import update_tags
@@ -71,9 +72,17 @@ def get_articles():
7172

7273
# Build main query with SQLAlchemy 2.0 select
7374
# Use .isnot(True) to include NULL processing values (old articles)
74-
stmt = sa.select(Article).where(
75-
Article.user_id == user.id,
76-
Article.processing.isnot(True),
75+
# Eager load tags and highlights to avoid N+1 queries
76+
stmt = (
77+
sa.select(Article)
78+
.where(
79+
Article.user_id == user.id,
80+
Article.processing.isnot(True),
81+
)
82+
.options(
83+
selectinload(Article.tags.and_(Tag.archived.is_(False))),
84+
selectinload(Article.highlights),
85+
)
7786
)
7887
stmt = aqm.filter_by_status(stmt, status)
7988
stmt = aqm.filter_by_tags(stmt, tag_ids)

app/api/helpers/article_query_maker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import sqlalchemy as sa
2+
from sqlalchemy.orm import selectinload
23

34
from app import db
4-
from app.models import Article, tags_articles
5+
from app.models import Article, Tag, tags_articles
56

67

78
def filter_by_status(stmt: sa.Select, status: str) -> sa.Select:
@@ -134,6 +135,10 @@ def get_recent_articles(user_id: int, limit: int = 3) -> list[Article]:
134135
Article.archived.is_(False),
135136
Article.date_read.isnot(None),
136137
)
138+
.options(
139+
selectinload(Article.tags.and_(Tag.archived == False)),
140+
selectinload(Article.highlights),
141+
)
137142
.order_by(Article.date_read.desc())
138143
.limit(limit)
139144
)

app/api/helpers/query_maker.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,32 @@ def get_total_count(stmt: sa.Select) -> int:
99
return db.session.scalar(count_stmt)
1010

1111

12-
def apply_pagination(stmt: sa.Select, page: str = "1", per_page: str = "15") -> tuple[list, bool]:
13-
"""Apply pagination to a select statement.
14-
15-
Args:
16-
stmt: SQLAlchemy select statement
17-
page: page number as string e.g "1"
18-
per_page: "all" or int string e.g "15" or "30"
19-
Returns:
20-
Tuple of (items list, has_next boolean)
21-
"""
12+
def apply_pagination(
13+
stmt: sa.Select,
14+
page: str = "1",
15+
per_page: str = "15",
16+
*,
17+
scalars: bool = True,
18+
) -> tuple[list, bool]:
19+
2220
page_num = int(page)
2321

2422
if per_page == "all":
25-
items = list(db.session.scalars(stmt))
23+
if scalars:
24+
items = list(db.session.scalars(stmt))
25+
else:
26+
items = list(db.session.execute(stmt))
2627
return items, False
2728

2829
per_page_num = int(per_page)
2930
offset = (page_num - 1) * per_page_num
3031

31-
# Get one extra to check if there's a next page
3232
paginated_stmt = stmt.offset(offset).limit(per_page_num + 1)
33-
items = list(db.session.scalars(paginated_stmt))
33+
34+
if scalars:
35+
items = list(db.session.scalars(paginated_stmt))
36+
else:
37+
items = list(db.session.execute(paginated_stmt))
3438

3539
has_next = len(items) > per_page_num
3640
if has_next:

app/api/highlights.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sqlalchemy as sa
2+
from sqlalchemy.orm import selectinload
23

34
from flask import request, jsonify, url_for
45
import json
@@ -16,7 +17,7 @@
1617
)
1718
from app.api.helpers.query_maker import apply_pagination, get_total_count
1819
from app.api.helpers.update_tags import update_tags
19-
from app.models import Highlight, Event
20+
from app.models import Highlight, Event, Tag
2021
from app.models.event import EventName
2122

2223

@@ -63,7 +64,12 @@ def get_highlights():
6364
tag_ids = request.args.get("tag_ids", None)
6465

6566
# Build query with SQLAlchemy 2.0 select
66-
stmt = sa.select(Highlight).where(Highlight.user_id == user.id)
67+
# Eager load non-archived tags to avoid N+1 queries
68+
stmt = (
69+
sa.select(Highlight)
70+
.where(Highlight.user_id == user.id)
71+
.options(selectinload(Highlight.tags.and_(Tag.archived.is_(False))))
72+
)
6773

6874
# Apply filters
6975
stmt = hqm.filter_by_status(stmt, status)
@@ -128,12 +134,12 @@ def create_highlight():
128134
return bad_request("Something went wrong")
129135

130136

131-
@bp.get("/highlights/<id>")
137+
@bp.get("/highlights/<uuid>")
132138
@token_auth.login_required
133-
def get_highlight(id):
139+
def get_highlight(uuid):
134140
try:
135141
user = token_auth.current_user()
136-
highlight = db.session.get(Highlight, id)
142+
highlight = db.session.scalars(db.select(Highlight).where(Highlight.uuid == uuid)).first()
137143
if not highlight or highlight.user_id != user.id:
138144
return error_response(404, "Resource not found")
139145

@@ -147,12 +153,12 @@ def get_highlight(id):
147153
return bad_request("Something went wrong")
148154

149155

150-
@bp.patch("/highlights/<id>")
156+
@bp.patch("/highlights/<uuid>")
151157
@token_auth.login_required
152-
def update_highlight(id):
158+
def update_highlight(uuid):
153159
try:
154160
user = token_auth.current_user()
155-
highlight = db.session.get(Highlight, id)
161+
highlight = db.session.scalars(db.select(Highlight).where(Highlight.uuid == uuid)).first()
156162
data = json.loads(request.data)
157163

158164
if not highlight or highlight.user_id != user.id:
@@ -182,12 +188,12 @@ def update_highlight(id):
182188
return bad_request("Something went wrong")
183189

184190

185-
@bp.delete("/highlights/<id>")
191+
@bp.delete("/highlights/<uuid>")
186192
@token_auth.login_required
187-
def delete_highlight(id):
193+
def delete_highlight(uuid):
188194
try:
189195
user = token_auth.current_user()
190-
highlight = db.session.get(Highlight, id)
196+
highlight = db.session.scalars(db.select(Highlight).where(Highlight.uuid == uuid)).first()
191197
if not highlight or highlight.user_id != user.id:
192198
return error_response(404, "Resource not found")
193199

app/api/tags.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from app.api import bp
88
from app.api.auth import token_auth
99
from app.api.errors import bad_request, LurnbyValueError, error_response
10-
from app.api.helpers.query_maker import apply_pagination
10+
from app.api.helpers.query_maker import apply_pagination, get_total_count
1111
from app.models import Event, Tag
12+
from app.models.associations import tags_highlights, tags_articles
1213
from app.models.event import EventName
1314

1415

@@ -22,23 +23,58 @@ def get_tags():
2223
page = request.args.get("page", "1")
2324
per_page = request.args.get("per_page", "150")
2425
status = request.args.get("status", "unarchived") # all / archived / unarchived
26+
search = request.args.get("q", "").strip()
2527

2628
user = token_auth.current_user()
2729

28-
# Build query with SQLAlchemy 2.0 select
29-
stmt = sa.select(Tag).where(Tag.user_id == user.id)
30+
# Build query with computed counts
31+
stmt = (
32+
sa.select(
33+
Tag,
34+
sa.func.count(sa.distinct(tags_highlights.c.highlight_id)).label("h_count"),
35+
sa.func.count(sa.distinct(tags_articles.c.article_id)).label("a_count"),
36+
)
37+
.outerjoin(tags_highlights, tags_highlights.c.tag_id == Tag.id)
38+
.outerjoin(tags_articles, tags_articles.c.tag_id == Tag.id)
39+
.where(Tag.user_id == user.id)
40+
.group_by(Tag.id)
41+
)
3042

3143
if status.lower() == "archived":
3244
stmt = stmt.where(Tag.archived.is_(True))
3345
elif status.lower() != "all":
3446
stmt = stmt.where(Tag.archived.is_(False))
3547

36-
stmt = stmt.order_by(Tag.name.asc())
48+
if search:
49+
stmt = stmt.where(Tag.name.ilike(f"%{search}%"))
3750

38-
tag_list, has_next = apply_pagination(stmt, page, per_page)
39-
tags = [tag.to_dict() for tag in tag_list]
51+
stmt = stmt.order_by(Tag.name.asc())
4052

41-
return jsonify(tags=tags, has_next=has_next)
53+
# Get total count before pagination
54+
count_stmt = sa.select(Tag).where(Tag.user_id == user.id)
55+
if status.lower() == "archived":
56+
count_stmt = count_stmt.where(Tag.archived.is_(True))
57+
elif status.lower() != "all":
58+
count_stmt = count_stmt.where(Tag.archived.is_(False))
59+
if search:
60+
count_stmt = count_stmt.where(Tag.name.ilike(f"%{search}%"))
61+
total = get_total_count(count_stmt)
62+
63+
rows, has_next = apply_pagination(
64+
stmt,
65+
page=page,
66+
per_page=per_page,
67+
scalars=False,
68+
)
69+
70+
tags = []
71+
for tag, h_count, a_count in rows:
72+
tag_dict = tag.to_dict()
73+
tag_dict["highlight_count"] = h_count
74+
tag_dict["article_count"] = a_count
75+
tags.append(tag_dict)
76+
77+
return jsonify(tags=tags, has_next=has_next, total=total)
4278

4379
except Exception as e:
4480
if isinstance(e, LurnbyValueError):
@@ -50,14 +86,33 @@ def get_tags():
5086

5187
@bp.route("/tags", methods=["POST"])
5288
@token_auth.login_required
53-
def tag():
89+
def create_tag():
5490
try:
5591
user = token_auth.current_user()
5692
data = json.loads(request.data) if request.data else None
5793
if not data or not data.get("name"):
5894
raise LurnbyValueError("Missing data in payload")
5995

60-
tag = Tag(user_id=user.id, name=data.get("name"))
96+
name = data.get("name").strip()
97+
98+
# Check if a tag with this name already exists
99+
existing_tag = Tag.query.filter(
100+
Tag.user_id == user.id,
101+
sa.func.lower(Tag.name) == name.lower(),
102+
).first()
103+
104+
if existing_tag:
105+
if existing_tag.archived:
106+
# Unarchive the existing tag
107+
existing_tag.archived = False
108+
ev = Event.add(EventName.UPDATED_TAG, user=user)
109+
db.session.add(ev)
110+
db.session.commit()
111+
return jsonify(tag=existing_tag.to_dict()), 200
112+
else:
113+
raise LurnbyValueError("A tag with this name already exists")
114+
115+
tag = Tag(user_id=user.id, name=name)
61116
if uuid := data.get("uuid"):
62117
tag.uuid = uuid
63118
db.session.add(tag)

app/client/routes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def highlights():
2121
return render_template("client/highlights.html")
2222

2323

24+
@bp.get("/tags")
25+
def tags():
26+
"""Tags management page"""
27+
return render_template("client/tags.html")
28+
29+
2430
@bp.get("/articles/<uuid>")
2531
@bp.get("/review")
2632
@bp.get("/settings")

app/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def article_export(user, articles, ext):
6868
f.write(a.source_url)
6969
f.write("\n")
7070
f.write("\n")
71-
tags = [t.name for t in a.tags.all()]
71+
tags = [t.name for t in a.tags]
7272
f.write(", ".join(tags))
7373
f.write("\n\n")
7474
f.write(notes)
@@ -104,7 +104,7 @@ def article_export(user, articles, ext):
104104
data["Title"] = "".join([c for c in a.title if c.isalpha() or c.isdigit()]).rstrip()
105105
data["Source"] = a.source_url if a.source_url else a.source
106106
data["Notes"] = a.notes
107-
data["Tags"] = [t.name for t in a.tags.filter_by(archived=False).all()]
107+
data["Tags"] = [t.name for t in a.tags if not t.archived]
108108
a_highlights = a.highlights.filter_by(archived=False).all()
109109
highlights = []
110110
for h in a_highlights:

app/helpers/export_helpers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def create_zip_file_for_article(article: Article, path: str, ext: str):
2929
zip_path = os.path.join(path, f"{zip_title}.zip")
3030

3131
article_dict = create_plain_text_article_dict(article)
32-
article_highlights = create_list_of_highlight_dicts(article.highlights.all())
32+
article_highlights = create_list_of_highlight_dicts(list(article.highlights))
3333

3434
if ext == "csv":
3535
article_path, highlights_path = export_to_csv(path, article_dict, article_highlights)
@@ -183,7 +183,7 @@ def create_list_of_highlight_dicts(highlights):
183183
"note": make_plain_text(highlight.note) if highlight.note else None,
184184
"prompt": make_plain_text(highlight.prompt) if highlight.prompt else None,
185185
"source": highlight.source,
186-
"tags": ", ".join([tag.name for tag in highlight.tags.all()]),
186+
"tags": ", ".join([tag.name for tag in highlight.tags]),
187187
"topics": ", ".join([topic.title for topic in highlight.topics.all()]),
188188
}
189189
)

app/helpers/on_demand/match_tags.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
def match_tags():
66
articles = Article.query.all()
77
for a in articles:
8-
tags = a.tags.all()
9-
for t in tags:
8+
for t in list(a.tags): # copy to avoid modification during iteration
109
if t.user_id != a.user_id:
1110
a.remove_tag(t)
1211
tag = Tag.query.filter_by(name=t.name, user_id=a.user_id).first()

0 commit comments

Comments
 (0)