One tag-name fold on the web and on Android, beside the core's (#5373, #5385)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 1m0s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m16s
CI & Build / integration (push) Successful in 2m41s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m26s
Android / Build the server image (push) Successful in 1s

The web compared or keyed tag names with name.toLowerCase() at five sites:
the labels store's merge check, LabelPicker's create check, NoteCard's
colour map, and colors.ts twice. Android had three, plus TagsScreen's rename
dialog using equals(ignoreCase = true). Each now calls foldTag(name), which
says what the core's derive::fold and the server's labeling.named say.

Android's rename dialog now predicts a merge the same way the core decides
one. Per-character ignoreCase and full lowercasing disagree on letters like
İ, whose lowercase is two characters. LabelPicker's search filter keeps
its own lowercasing: that is search, not identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:05:30 -04:00
co-authored by Claude Opus 5.5
parent 0ecff7eeb9
commit d6204d320a
8 changed files with 36 additions and 18 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useLabelsStore, type Label } from "../stores/labels";
import Icon from "./Icon.vue";
import type { NoteLabel } from "../stores/notes";
import { foldTag } from "../notes/tags";
// A label attached via the picker is manual (via_tag: false).
const asManual = (lb: Label): NoteLabel => ({ id: lb.id, name: lb.name, color: lb.color, via_tag: false });
@@ -21,7 +22,7 @@ const shown = computed(() =>
);
const canCreate = computed(() => {
const name = filter.value.trim();
return name.length > 0 && !labels.items.some((lb) => lb.name.toLowerCase() === name.toLowerCase());
return name.length > 0 && !labels.items.some((lb) => foldTag(lb.name) === foldTag(name));
});
function toggle(lb: Label) {
+4 -3
View File
@@ -20,6 +20,7 @@ import {
import { formatShortDateTime, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
import { canEditText, isOwnNote } from "../notes/sharing";
import { foldTag } from "../notes/tags";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
@@ -200,12 +201,12 @@ watch(
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
// The colour the operator stored for each of this note's tags, keyed by lowercased
// The colour the operator stored for each of this note's tags, keyed by folded
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
// Folded because `#Todo` and `#todo` are one tag.
const tagColors = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
for (const lb of props.note.labels) map[foldTag(lb.name)] = lb.color;
return map;
});
+8 -6
View File
@@ -2,6 +2,8 @@
// here (frontend concern). Class strings are full literals so Tailwind's content
// scanner (src/**/*.ts) keeps them in the build.
import { foldTag } from "./tags";
export const NOTE_COLOR_KEYS = [
"default",
"red",
@@ -82,13 +84,13 @@ export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
/**
* The classes for one `#tag` in a note's own words.
*
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
* `picked` maps a folded tag name (`foldTag`) to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[foldTag(name)] })];
}
/**
@@ -200,15 +202,15 @@ export function derivedTint(id: string): NoteColor {
* An explicitly-picked colour is still stored and still wins, so tag colours stay
* editable exactly as asked.
*
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
* `lowercase()` are locale-independent, so the mirror holds.
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
* colour. `foldTag` here and Android's are both locale-independent lowercasing, so
* the mirror holds.
*/
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
const picked = label.color as NoteColor | undefined | null;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
if (!label.name) return "default";
return derivedTint(label.name.toLowerCase());
return derivedTint(foldTag(label.name));
}
// The fixture (four UUID hashes and seven tag names) lives in
+6
View File
@@ -0,0 +1,6 @@
// How two tag names compare: equal once every letter is lowercased, accented ones
// included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the
// server's `labeling.named` and Android's `foldTag` fold the same way (#5385).
export function foldTag(name: string): string {
return name.toLowerCase();
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { foldTag } from "../notes/tags";
export interface Label {
id: string;
@@ -45,7 +46,7 @@ export const useLabelsStore = defineStore("labels", () => {
// the response: the survivor is whichever row is older, so it may well be the
// one we asked to rename, and an id that still matches proves nothing happened.
const absorbing = items.value.find(
(lb) => lb.id !== id && lb.name.toLowerCase() === name.toLowerCase(),
(lb) => lb.id !== id && foldTag(lb.name) === foldTag(name),
);
if (absorbing) {
// A merge cannot be undone by repeating it, and here it is reachable by a