50 tag relations#120
Conversation
…ocated category manager
… without scrolling
PR #120 Review: Tag Relations (
|
| Check | Status | Duration |
|---|---|---|
| CodeQL | ✅ success | ~2s |
| lint (Biome) | ✅ success | ~22s |
| test (unit) | ✅ success | ~4m 7s |
| Analyze (actions) | ✅ success | ~39s |
| Analyze (javascript-typescript) | ✅ success | ~1m 6s |
| test (unit #2) | ✅ success | ~25s |
Unit tests: 146 tests across 13 test files — all passing.
Production build: compiles successfully with TypeScript and route optimization.
Commit Message Compliance (Conventional Commits)
Tip
Overall the commit messages are well-structured and follow Conventional Commits.
The 47 commits follow the convention spec correctly with appropriate scopes:
| Convention | Count | Examples |
|---|---|---|
feat(...) |
37 | feat(db): add tag_categories table and tags.categoryId FK |
fix(...) |
4 | fix(tags): resolve category filtering for uncategorized tags |
test(...) |
3 | test(db): add unit tests for tag category repository |
refactor(...) |
2 | refactor(settings): relocate tag category management out of settings |
docs(...) |
1 | docs(tags): document tag category relationships and styling variables |
style(...) |
1 | style(ui): make modal autocomplete dropdown visible |
Commits are logically incremental, organized per-phase and per-layer (DB → actions → UI), which aligns with CONTRIBUTING.md §3.1 (plan per page, not per layer) and the user's commit strategy rules.
Note
One commit (8ba96705) uses a non-conventional format: 110 add unitintegration testing for server actions and repository layer (#116) — however, this is a merge commit from a prior PR and is not part of this branch's authored work.
Contributor Guideline Compliance
✅ Patterns Correctly Followed
| Guideline | Status | Notes |
|---|---|---|
| §1.1 Server + Client split | ✅ | /tags/manage/page.tsx (server) + page-client.tsx (client) |
| §1.2 Drizzle + async | ✅ | All repository functions use await, relational queries with with: |
| §1.3 Cursor-based pagination | ✅ | getTagsPaginated returns { items, nextCursor } |
| §1.4 Strategy + Factory | ✅ | Gelbooru/E-Hentai processors updated, no if/else in base |
| §1.5 CSS Modules + tokens | ✅ | New pages use co-located .module.css, HSL variables |
| §1.6 Shared hooks | ✅ | Reuses usePaginatedData, useSelection, useInfiniteScroll |
| §1.8 Component extraction | ✅ | Sub-components import parent page.module.css |
| §1.9 Actions split by domain | ✅ | All new actions in src/app/actions/tags.ts |
| §1.11 Security | ✅ | No sql.raw(), parameterized queries only |
| §1.14 Statistics | ✅ | incrementStatistics/recomputeStatistics called after mutations |
| §2.2 Path aliases | ✅ | All imports use @/ aliases |
| §2.7 Per-page metadata | ✅ | /tags/manage/page.tsx exports metadata |
| §2.8 Conventional commits | ✅ | See above analysis |
| §4 Testing | ✅ | Co-located repo tests + centralized action tests |
⚠️ Findings Requiring Attention
Finding 1: ARCHITECTURE.md missing documentation for Phases 3–5 (HIGH)
Important
Per CONTRIBUTING.md §2.9: "When implementing new features, modifications to subsystems, or introducing new architectural patterns, you MUST update both ARCHITECTURE.md and CONTRIBUTING.md."
The ARCHITECTURE.md was updated for Phase 1 only (tag categories — directory map, schema diagram, and key relationships). However, the following major architectural additions from Phases 3–5 are not documented:
| Feature | Schema/Code Change | Documented? |
|---|---|---|
tags.aliasOfTagId (self-ref FK) |
Migration 0014, schema.ts |
❌ Not in ARCHITECTURE.md |
tags.parentTagId (self-ref FK) |
Migration 0015, schema.ts |
❌ Not in ARCHITECTURE.md |
tag_relations join table |
Migration 0016, schema.ts |
❌ Not in ARCHITECTURE.md |
totalCanonicalTags counter |
Added to library_statistics + statistics_history |
❌ Not in ARCHITECTURE.md |
implicitHierarchyFiltering setting |
Added to AppSettings |
❌ Not in ARCHITECTURE.md |
| Recursive CTE search expansion | expandSearchTags in posts.ts / media.ts |
❌ Not in ARCHITECTURE.md |
/api/tags/children route |
New route handler with offset pagination | ❌ Not in ARCHITECTURE.md or directory map |
Specifically missing from ARCHITECTURE.md:
- §2 Directory Map: Missing
/api/tags/children/entry - §5 Database Schema: The schema diagram should show
tags.aliasOfTagId → tags.id,tags.parentTagId → tags.id, and thetag_relationsjoin table - §5 Key Relationships: Missing self-referencing FK descriptions and the symmetric pair constraint
- §3.7 Settings: Missing
implicitHierarchyFilteringin the settings description - §3.9 Statistics: Missing
totalCanonicalTagsin the statistics description
Finding 2: CONTRIBUTING.md missing rules for tag aliases, hierarchy, and relations (MEDIUM)
The CONTRIBUTING.md was updated with two new rules (§1.4 tag category prefix stripping, §1.5 dynamic HSL category variables). However, no guidelines were added for:
- Tag alias rules: Flat alias hierarchy constraint (no chaining), category inheritance semantics, alias blocking on category changes
- Tag hierarchy rules: Circular dependency prevention, alias-parent mutual exclusion,
implicitHierarchyFilteringbehavior - Tag relations rules: Symmetric pair constraint (
tag_id < related_tag_id), alias exclusion - Search expansion pattern: How
expandSearchTagsworks with recursive CTEs and when it applies
Finding 3: Static inline style in StatCard.tsx (LOW)
Per CONTRIBUTING.md §1.5: "Only use inline
style={{}}for truly dynamic runtime values. All static styling belongs in CSS Modules."
StatCard.tsx L57 introduces a static inline style:
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>This is a fixed layout style and should be a CSS Module class. Suggested fix: add a .statCardHeaderRight class to the statistics page.module.css.
Finding 4: as any type cast in tags route handler (LOW)
Per CONTRIBUTING.md §2.1: "Avoid
any— if a type is unknown, useunknownand narrow it."
route.ts L8:
const sortBy = (searchParams.get("sortBy") as any) || "count";Should be typed as a union:
const sortBy = (searchParams.get("sortBy") as "count" | "name" | "recent") || "count";Finding 5: rgba(0, 0, 0, ...) in settings page.module.css (LOW — pre-existing)
page.module.css L405:
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);This is a rgba hardcoded shadow color in the new category card styles. While the existing code (L30, L306) already uses rgba(0,0,0,...) for shadows, the CONTRIBUTING.md rule states no hardcoded colors. This is a pre-existing pattern, but the new code perpetuates it.
Note
This is a systemic issue across the codebase and not unique to this PR. Consider addressing it as a separate cleanup task.
Finding 6: Missing loading.tsx / error.tsx for /tags/manage/ (MEDIUM)
Per CONTRIBUTING.md §1.10: "Every route segment should have
loading.tsxanderror.tsx."
The /tags/manage/ route has page.tsx and page-client.tsx but the subagent research confirmed these boundaries exist at the parent /tags/ level. However, per the convention, the manage/ sub-route should have its own boundaries for deterministic loading states, especially given it loads categories server-side.
Check needed: Verify whether /tags/manage/ inherits from /tags/loading.tsx or needs its own. If the parent covers it, this is acceptable.
Finding 7: Offset-based pagination in /api/tags/children (OBSERVATION)
Per CONTRIBUTING.md §1.3: "List endpoints use cursor-based pagination, not offset-based."
The /api/tags/children route handler uses offset-based pagination (cursor is actually an offset integer). The PR description explicitly documents this decision:
"Upgraded pagination from cursor to offset-based pagination to support stable sorting by child counts and custom order rules."
This is a justified deviation — offset pagination is necessary here because the sort order includes computed values (childCount) that would make cursor-based pagination unstable. The PR description explains the rationale clearly.
Tip
Consider adding this exception to CONTRIBUTING.md §1.3 to prevent future confusion: "Exception: tree-structured endpoints that sort by computed aggregates (e.g., /api/tags/children sorted by child count) may use offset-based pagination when cursor stability cannot be guaranteed."
PR Description Quality
Tip
The PR description is exceptionally thorough. It includes per-phase walkthroughs, file-level change descriptions, verification results (test counts, build output), and links to affected files. This is exemplary.
One suggestion: The PR description could be structured with GitHub's <details> collapse blocks for each phase to improve readability, as it's currently ~400+ lines long.
Summary of Action Items
Must Fix Before Merge
| # | Severity | Finding | Action |
|---|---|---|---|
| 1 | 🔴 HIGH | ARCHITECTURE.md missing Phases 3–5 documentation | Add schema diagram entries for aliasOfTagId, parentTagId, tag_relations, totalCanonicalTags. Update directory map with /api/tags/children. Document settings addition and search expansion pattern. |
| 2 | 🟡 MEDIUM | CONTRIBUTING.md missing alias/hierarchy/relation rules | Add rules for alias flat hierarchy constraint, hierarchy circular prevention, relation symmetry, and search expansion behavior. |
| 3 | 🟡 MEDIUM | Missing /tags/manage/ loading/error boundaries |
Add loading.tsx and error.tsx to /tags/manage/, or document that parent route boundaries cover it. |
Should Fix (Non-blocking)
| # | Severity | Finding | Action |
|---|---|---|---|
| 4 | 🟢 LOW | Static inline style in StatCard.tsx | Move display: flex; align-items: center; gap: 0.5rem to a CSS Module class. |
| 5 | 🟢 LOW | as any type cast in tags route |
Replace with proper union type. |
| 6 | 🟢 LOW | rgba() in new CSS styles |
Pre-existing pattern — track as future cleanup. |
| 7 | — INFO | Offset pagination deviation | Add exception note to CONTRIBUTING.md §1.3. |
… exception in CONTRIBUTING.md
Walkthrough - PR #120 Action Items ResolutionI have completed the implementation plan to address the action items from the code review of PR #120 (Tag Relations). Changes Made1. Documentation Updates
2. UI & Route Boundaries
3. Code Quality & Style Adjustments
Verification ResultsAutomated Checks & Tests
|
This PR is being implemented in phases according to #50.
Phase 1: Tag Categories & Custom Categories
Walkthrough: Tag Categories & Metadata Processing
We have successfully implemented Phase 1: Tag Categories & Custom Categories for Issue #50. Below is a detailed summary of the remaining commits completed in this session.
Changes Made
1. Gelbooru Auto-Import (Commit 7)
syncLibrary()in scanner.ts to pre-load all database tag categories on scan initialization.categoryMapviaProcessorContextto metadata processors.character:,artist:,copyright:,metadata:/meta:), strip the prefix before saving, and assign the correct category ID.2. E-Hentai Auto-Import (Commit 8)
artist:,character:,cosplayer:,group:,circle:,parody:,language:,reclass:, andfemale:/male:namespace prefix selectors) and assign the matching category ID.3. UI - Tags Page Enhancements (Commit 9)
4. UI - Lightbox Detail Panel (Commit 10)
TagWithCategoryobjects fromgetPostTags.createOrFindTag()in tags.ts to eager-load the category relation on tag creation.5. UI - Settings Category Management (Commit 11)
6. Documentation (Commit 12)
7. Removal of "General" Category
"general"category from system/test category seeding.male/female) do not get assigned a category (i.e.categoryIdremainsnull/ uncategorized).Verification & Test Results
1. Unit & Integration Tests
All 87 tests compile and pass successfully, confirming that all repository and action operations function correctly.
2. Next.js Production Build
Verified that a production build of the Next.js application compiles successfully with all routes optimized.
Phase 2: Tag Management Page
Walkthrough - Phase 2 (Tag Management Page)
We have successfully completed all implementation and verification steps for Phase 2 of Issue #50 (Tag Relations & Management Page).
Changes Made
1. Database & Repository Layer
getTagsPaginated: Cursor-based dynamic pagination with filters (search query, category name, uncategorized) and sorting (Most Popular, Alphabetical)."none"as the category parameter, while the backend expected"uncategorized". The query filter condition now checks for both"none"and"uncategorized"to returncategoryId IS NULLrecords correctly.renameTag: Rename validation and execution.mergeTags: Transfer all post-tag relations from multiple source tags to a single target tag, cascading database entries, and purging source tags without breaking transaction safety.deleteTag/deleteTags: Cascade delete post-tag relations and tags.cleanupOrphanedTags: Automatically clean up unused tags with 0 post associations.2. Server Actions & Types
renameTagmergeTags(with library stats adjustment:totalTagscounts are decremented when tags are merged).deleteTag/deleteTags(with library stats adjustment).cleanupOrphanedTags(with library stats adjustment).bulkSetTagCategorygetOrCreateTagByName(facilitates auto-creating the target tag if a user types a new tag name in the merge modal).3. API Route Handler
/api/tagsto serve client-side cursor-paginated GET queries.4. Tag Category Relocation
5. Tag Management UI & Dashboards
/tags/manage:InfiniteScrollSentinelcomponent to allow tags to scroll infinitely as the user reaches the bottom of the table./api/autocomplete?column=tag&q=...) or a brand-new tag.Verification Results
Automated Unit Tests
"none"and"uncategorized"query parameters return uncategorized tags (33/33 passed).Production Build Validation
npm run buildwhich verified successful TypeScript compilation and compiled route layouts:Phase 3: Tag Aliases
Walkthrough: Tag Aliases (Issue #50 Phase 3)
This walkthrough documents the completion of Phase 3 of Issue #50, which implements self-referencing tag aliases, query-time bi-directional search expansion, tag category inheritance, and UI management/statistics updates.
Changes Made
1. Database Schema
aliasOfTagIdon thetagstable referencingtags.idwithonDelete: "set null".totalCanonicalTagscolumn to bothlibraryStatisticsandstatisticsHistorytables to track tags that are not aliases.drizzle/0014_polite_mandarin.sql.2. Repository Layer & Logic
getTagsPaginatedto left-jointagson itself to fetchaliasNameandaliasOfTagIdfor UI rendering.setTagAlias(tagId, aliasOfTagId)with flat hierarchy validation (disallowing circular alias definitions or aliasing to tags that are themselves aliases). Setting an alias automatically syncs its category with the canonical tag. Clearing an alias preserves the inherited category.bulkSetTagAlias(tagIds, aliasOfTagId)with batch operations and category inheritance syncing.setTagCategoryandbulkSetTagCategoryto block category changes directly on alias tags (throwing an error) and instead propagate category updates on canonical tags automatically to all of their aliases.recomputeStatistics,incrementStatistics,recordHistorySnapshot, andgetHistoryto calculate and maintaintotalCanonicalTagscounters.expandSearchAliases(query)to resolve tag synonyms. Before matching FTS, the code extractstag_names:<name>filters, fetches equivalent tags (canonical and sibling aliases) from the DB, and rewrites the FTS clauses into bi-directionalORstructures (e.g.(tag_names:car OR tag_names:automobile)).autocompleteTagto queryaliasOfTagIdandaliasNameusing a self-join. It maps matching tags to redirect formats (car → automobile).3. Server Actions & UI
setTagAliasandbulkSetTagAliasas server actions.deleteTag,deleteTags,mergeTags,cleanupOrphanedTags) to callrecomputeStatistics()to keep all canonical tag counts perfectly in sync after database cascading foreign key events (onDelete: "set null").↳ automobilewith a redirect icon).<select>dropdown for category selection with a dedicated "Set Category" button in bulk operations and a matching icon button (FolderEdit) on individual canonical tag rows..modaland.modalBodyoverflow values tovisiblefor standard modals, letting autocomplete list overlays float cleanly in front of confirmation buttons and outside the modal bounds without causing container resizing or scrollbars. Added border radius inheritance to header/footer blocks to prevent corner background clipping issues.StatCardto accept custom action items (inline buttons).totalTagsandtotalCanonicalTags.Verification Results
Unit Tests
Ran
npm run test:unitand verified all 121 tests passed successfully:tags.test.tsrepository tests verifying tag alias validation (throws on circular references, flat validation check, canonical statistics delta calculation).tags.test.tsrepository tests verifying tag alias category inheritance (setting alias inherits category, clearing preserves category, updating canonical tag category propagates to all aliases, and setting category directly on an alias throws an error).posts.test.tsrepository tests verifying FTS search expansion expands searches to include both aliases and canonical equivalents.autocomplete.test.tsrepository tests verifyingautocompleteTagsuggests formatted redirects.setTagAliasandbulkSetTagAliasactions.Build Check
Ran
npx tsc --noEmitand confirmed clean compilation with zero TypeScript errors.Phase 4: Tag Hierarchy
Walkthrough: Tag Hierarchy Implementation (Phase 4 of Issue #50)
We have successfully implemented and verified the final phase of the Tag Hierarchy feature. This establishes a fully functional self-referencing hierarchy system for tags with implicit search expansion, settings management, repository validations, server actions, unit tests, and multiple interactive UI layouts.
Changes Implemented
1. Database & Schema
parentTagIdcolumn to thetagstable inschema.ts.tagsRelationsdefiningparentTagandchildTags.drizzle/to apply the database changes.2. Settings & Configuration
implicitHierarchyFilteringtoAppSettingsintypes/settings.ts(defaulting totrue)./settingsto enable/disable implicit search expansion.3. Repository & API Layer
setTagParentandbulkSetTagParentinsrc/lib/db/repositories/tags.ts.A -> B -> C -> A).getTagsPaginatedto retrieveparentTagIdandparentNamefor the tag list layout.expandSearchAliasestoexpandSearchTagsinsrc/lib/db/repositories/posts.tsandmedia.ts.WITH RECURSIVECTE in Drizzle to resolve descendants of queried tags. When a query is made on a parent (e.g.tag:animal), it expands to include all direct and indirect descendants (e.g.(tag:animal OR tag:dog OR tag:cat)) alongside their aliases./api/tags/childrento calculate directchildCountfor each tag.childCount DESC(the number of direct sub-tags at each level).hierarchiesFirst=truequery option to order tags that have nested child tags to the top of the level.hideOrphans=truequery option to hide top-level tags that have no children (orphans).autocompleteTaginsrc/lib/db/repositories/autocomplete.tsto recursively resolve the list of ancestor tag names.4. Server Actions
setTagParentandbulkSetTagParentas Next.js server actions insrc/app/actions/tags.ts.5. Frontend & UI Elements
/tags/manage):ChevronUp) and bulk action button to assign parent tags.SetParentModalsupporting autocomplete for parent selection and parent clearance./tags):/api/tags/children.3 subtags)./gallerylightbox):TagAutocompleteInputto pass ancestor suggestions.cat) prompts the user with button suggestions to easily add unassigned ancestor tags (e.g.+ feline,+ animal) with a single click.Verification & Testing
Automated Tests
We added unit tests verifying all new logic, and all tests pass successfully (
npm run test:unit):tests/unit/api/tags-children.test.ts(NEW):sortBy=childCountis active.hierarchiesFirst=trueis active.hideOrphans=trueis active.tests/unit/actions/tags.test.ts&src/lib/db/repositories/tags.test.ts:src/lib/db/repositories/posts.test.ts:expandSearchTagsexpands child tag matches whenimplicitHierarchyFilteringis true.implicitHierarchyFilteringis false.src/lib/db/repositories/autocomplete.test.ts:Production Build Verification
The complete application successfully compiled for production via
npm run build.Phase 5: Related Tags
Walkthrough: Related Tags (Issue #50 Phase 5)
We have successfully implemented and verified Phase 5 (Related Tags) of Issue #50. This completes the final phase of the tag relations system on the
50-tag-relationsbranch.Below is a detailed summary of the changes made and verified in this session.
Changes Made
1. Database Schema & Migrations
tag_relationsjoin table.(tag_id, related_tag_id)to prevent duplicate links.CHECK (tag_id < related_tag_id)constraint via Drizzle to enforce ordering of symmetric pairs directly in SQLite.tags.id.relatedTagsandrelatedToTagsrelations intagsRelationsandtagRelationsRelations.drizzle/0016_oval_corsair.sql.2. Repository Layer
addRelatedTag: Creates a relation. Automatically orderstagId1andtagId2to satisfy the database check constraint, prevents self-relating, and throws an error if either tag is an alias.removeRelatedTag: Deletes a relation.getRelatedTags: Retrieves a list of related tag objects.getSuggestedRelatedTags: Given post tags, suggests related tags sorted by association frequency, excluding tags already present on the post.3. Server Actions
addRelatedTag,removeRelatedTag,getRelatedTags, andgetSuggestedRelatedTagsas React server actions.4. UI - Tag Management Page
Networkicon) to individual tag rows in the dashboard table.RelatedTagsModal:removeRelatedTagand updates the state.5. UI - Lightbox Suggestions
useEffecthook to fetch related tag suggestions based on currently active tags on the post.Verification & Test Results
1. Automated Tests
All unit tests compile and pass successfully, confirming that all new relation operations, validations, suggestions, and UI actions function correctly.
2. Next.js Production Build
Verified that a production build of the Next.js application compiles successfully with all routes optimized.