2 Commits

Author SHA1 Message Date
H1K0 88a8cac048 fix(frontend): load full tag/category lists in pickers, honoring sort
deploy / deploy (push) Successful in 19s
The tag pickers, filter bar and batch editor loaded only /tags?limit=200 and
filtered client-side, so with more than 200 tags the rest were invisible and
unsearchable. Same for the category dropdowns on the tag forms.

Add fetchAllTags / fetchAllCategories helpers that page past the server's
per-request cap of 200, and order results by the sort the user chose on the
tags / categories page (tagSorting / categorySorting) instead of a hardcoded
name-asc. Wire them into FilterBar, TagPicker, TagRuleEditor, BulkTagEditor and
the tag new/edit category dropdowns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:58:23 +03:00
H1K0 aae0c587e8 fix(scripts): correct autotag rule direction in legacy migration
The old autotags worked child -> parent (adding child_id pulled in parent_id,
per tfm__add_file_to_tag_recursive). The new tag_rules model is "when_tag
applied -> then_tag follows", so child must map to when_tag and parent to
then_tag. The transform had it reversed, so migrated rules never fired —
adding a tag added only itself.

Map child_id -> when_tag_id, parent_id -> then_tag_id. Verified on a scratch DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:45:52 +03:00
10 changed files with 90 additions and 25 deletions
+28
View File
@@ -0,0 +1,28 @@
import { get } from 'svelte/store';
import { api } from '$lib/api/client';
import type { Category, CategoryOffsetPage } from '$lib/api/types';
import { categorySorting } from '$lib/stores/sorting';
// The /categories endpoint caps limit at 200 per request. Category dropdowns
// show the whole list, so page through to get them all — otherwise categories
// past the first 200 are missing from the picker.
const PAGE = 200;
/**
* Fetches every category, paging past the server's per-request cap. Ordered by
* the sort the user picked on the categories page (categorySorting).
*/
export async function fetchAllCategories(): Promise<Category[]> {
const { sort, order } = get(categorySorting);
const all: Category[] = [];
for (let offset = 0; ; offset += PAGE) {
const page = await api.get<CategoryOffsetPage>(
`/categories?limit=${PAGE}&offset=${offset}&sort=${sort}&order=${order}`
);
const items = page.items ?? [];
all.push(...items);
const total = page.total ?? all.length;
if (items.length < PAGE || all.length >= total) break;
}
return all;
}
+30
View File
@@ -0,0 +1,30 @@
import { get } from 'svelte/store';
import { api } from '$lib/api/client';
import type { Tag, TagOffsetPage } from '$lib/api/types';
import { tagSorting } from '$lib/stores/sorting';
// The /tags endpoint caps limit at 200 per request. Pickers and the filter bar
// filter the tag list client-side, so they need the *whole* list — otherwise
// tags past the first 200 are invisible and unsearchable. Page through until we
// have them all.
const PAGE = 200;
/**
* Fetches every tag, paging past the server's per-request cap. Ordered by the
* sort the user picked on the tags page (tagSorting), so the pickers and filter
* bar show tags in the same order as that page.
*/
export async function fetchAllTags(): Promise<Tag[]> {
const { sort, order } = get(tagSorting);
const all: Tag[] = [];
for (let offset = 0; ; offset += PAGE) {
const page = await api.get<TagOffsetPage>(
`/tags?limit=${PAGE}&offset=${offset}&sort=${sort}&order=${order}`
);
const items = page.items ?? [];
all.push(...items);
const total = page.total ?? all.length;
if (items.length < PAGE || all.length >= total) break;
}
return all;
}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api/client'; import { api } from '$lib/api/client';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import type { Tag } from '$lib/api/types';
import { fetchAllTags } from '$lib/api/tags';
interface Props { interface Props {
fileIds: string[]; fileIds: string[];
@@ -30,13 +31,13 @@
error = ''; error = '';
try { try {
const [tagsRes, commonRes] = await Promise.all([ const [tagsRes, commonRes] = await Promise.all([
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc'), fetchAllTags(),
api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>( api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
'/files/bulk/common-tags', '/files/bulk/common-tags',
{ file_ids: fileIds } { file_ids: fileIds }
) )
]); ]);
allTags = tagsRes.items ?? []; allTags = tagsRes;
commonIds = new Set(commonRes.common_tag_ids ?? []); commonIds = new Set(commonRes.common_tag_ids ?? []);
partialIds = new Set(commonRes.partial_tag_ids ?? []); partialIds = new Set(commonRes.partial_tag_ids ?? []);
} catch { } catch {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api/client'; import type { Tag } from '$lib/api/types';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import { fetchAllTags } from '$lib/api/tags';
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl'; import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
interface Props { interface Props {
@@ -26,8 +26,8 @@
}); });
$effect(() => { $effect(() => {
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((page) => { fetchAllTags().then((all) => {
tags = page.items ?? []; tags = all;
}); });
}); });
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api/client'; import type { Tag } from '$lib/api/types';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import { fetchAllTags } from '$lib/api/tags';
interface Props { interface Props {
fileTags: Tag[]; fileTags: Tag[];
@@ -15,8 +15,8 @@
let busy = $state(false); let busy = $state(false);
$effect(() => { $effect(() => {
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => { fetchAllTags().then((all) => {
allTags = p.items ?? []; allTags = all;
}); });
}); });
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types'; import type { Tag, TagRule } from '$lib/api/types';
import { fetchAllTags } from '$lib/api/tags';
import TagBadge from './TagBadge.svelte'; import TagBadge from './TagBadge.svelte';
import { appSettings } from '$lib/stores/appSettings'; import { appSettings } from '$lib/stores/appSettings';
@@ -18,8 +19,8 @@
let error = $state(''); let error = $state('');
$effect(() => { $effect(() => {
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => { fetchAllTags().then((all) => {
allTags = p.items ?? []; allTags = all;
}); });
}); });
+4 -3
View File
@@ -2,7 +2,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types'; import type { Category, Tag, TagRule } from '$lib/api/types';
import { fetchAllCategories } from '$lib/api/categories';
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte'; import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -32,12 +33,12 @@
loadError = ''; loadError = '';
void Promise.all([ void Promise.all([
api.get<Tag>(`/tags/${id}`), api.get<Tag>(`/tags/${id}`),
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'), fetchAllCategories(),
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[]) api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[])
]) ])
.then(([t, cats, r]) => { .then(([t, cats, r]) => {
tag = t; tag = t;
categories = cats.items ?? []; categories = cats;
rules = r; rules = r;
name = t.name ?? ''; name = t.name ?? '';
+4 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import type { Category, CategoryOffsetPage } from '$lib/api/types'; import type { Category } from '$lib/api/types';
import { fetchAllCategories } from '$lib/api/categories';
let name = $state(''); let name = $state('');
let notes = $state(''); let notes = $state('');
@@ -13,8 +14,8 @@
let categories = $state<Category[]>([]); let categories = $state<Category[]>([]);
$effect(() => { $effect(() => {
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc').then((p) => { fetchAllCategories().then((all) => {
categories = p.items ?? []; categories = all;
}); });
}); });
+1 -1
View File
@@ -56,7 +56,7 @@ psql "$NEW_DSN" -v ON_ERROR_STOP=1 -f transform.sql
| `mime` | `core.mime_types` | id **uuid → smallint** (remapped by `name`); types not already seeded are added | | `mime` | `core.mime_types` | id **uuid → smallint** (remapped by `name`); types not already seeded are added |
| `categories` | `data.categories` | id kept; `is_private`**`is_public`** (inverted) | | `categories` | `data.categories` | id kept; `is_private`**`is_public`** (inverted) |
| `tags` | `data.tags` | id + `category_id` kept; inverted privacy | | `tags` | `data.tags` | id + `category_id` kept; inverted privacy |
| `autotags` | `data.tag_rules` | `parent_id``when_tag_id`, `child_id``then_tag_id` | | `autotags` | `data.tag_rules` | `child_id``when_tag_id`, `parent_id``then_tag_id` (old: adding the child pulled in the parent) |
| `files` | `data.files` | id kept; `datetime``content_datetime`; `orig_name``original_name`; **EXIF** lifted from `metadata->'exif'` into the `exif` column, the rest stays as user `metadata` | | `files` | `data.files` | id kept; `datetime``content_datetime`; `orig_name``original_name`; **EXIF** lifted from `metadata->'exif'` into the `exif` column, the rest stays as user `metadata` |
| `file_tag` | `data.file_tag` | orphan rows skipped | | `file_tag` | `data.file_tag` | orphan rows skipped |
| `pools` | `data.pools` | id kept; `parent_id` + `created` preserved under `metadata` (see below) | | `pools` | `data.pools` | id kept; `parent_id` + `created` preserved under `metadata` (see below) |
+7 -4
View File
@@ -92,14 +92,17 @@ JOIN user_id_map um ON um.old_id = t.creator_id
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 5. Tag rules (old `autotags`): parent -> when_tag, child -> then_tag. -- 5. Tag rules (old `autotags`). In the old schema adding the child tag pulled
-- in its parent (tfm__add_file_to_tag_recursive: for child_id = t_id it adds
-- parent_id). The new model is "when_tag applied -> then_tag follows", so the
-- trigger child becomes when_tag and the auto-applied parent becomes then_tag.
-- Skip rules whose tags didn't migrate. -- Skip rules whose tags didn't migrate.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
INSERT INTO data.tag_rules (when_tag_id, then_tag_id, is_active) INSERT INTO data.tag_rules (when_tag_id, then_tag_id, is_active)
SELECT a.parent_id, a.child_id, a.is_active SELECT a.child_id, a.parent_id, a.is_active
FROM legacy.autotags a FROM legacy.autotags a
WHERE EXISTS (SELECT 1 FROM data.tags t WHERE t.id = a.parent_id) WHERE EXISTS (SELECT 1 FROM data.tags t WHERE t.id = a.child_id)
AND EXISTS (SELECT 1 FROM data.tags t WHERE t.id = a.child_id) AND EXISTS (SELECT 1 FROM data.tags t WHERE t.id = a.parent_id)
ON CONFLICT (when_tag_id, then_tag_id) DO NOTHING; ON CONFLICT (when_tag_id, then_tag_id) DO NOTHING;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------