diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/DerivedTint.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/DerivedTint.kt index 1ceb496..15e50cb 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/DerivedTint.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/DerivedTint.kt @@ -86,9 +86,9 @@ fun derivedTint(id: String): String { * Deriving has no mint points to miss and no backfill for the tags already out there. * The cost is that renaming a tag recolours it, which is fair: the name IS the tag. * - * Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is - * the same tag and should not change colour. Kotlin's `lowercase()` and the web's - * `toLowerCase()` are both locale-independent, so the mirror holds. + * Folded because `#Todo` renamed to `#todo` is the same tag and should not change + * colour. [foldTag] and the web's are both locale-independent lowercasing, so the + * mirror holds. */ fun resolvedLabelColor( name: String, @@ -98,5 +98,12 @@ fun resolvedLabelColor( when { color.isNotEmpty() && color != "default" && color in known -> color name.isEmpty() -> "default" - else -> derivedTint(name.lowercase()) + else -> derivedTint(foldTag(name)) } + +/** + * 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 the web's `foldTag` fold the same way (#5385). + */ +fun foldTag(name: String): String = name.lowercase() diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt index 22c79bf..0ee1a13 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt @@ -366,11 +366,11 @@ private fun tintTags( // A tag the note does not carry as a label yet — just typed, not yet // derived — still gets a colour: `labelTint` falls back to deriving one // from the name, which is what the chip would have shown anyway. - val picked = note.labels.associate { it.name.lowercase() to it.color } + val picked = note.labels.associate { foldTag(it.name) to it.color } buildAnnotatedString { append(text) spans.forEach { tag -> - val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty()) + val tint = labelTint(tag.name, picked[foldTag(tag.name)].orEmpty()) addStyle( SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium), tag.start.toInt(), diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsScreen.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsScreen.kt index 66270f7..ada54b1 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/TagsScreen.kt @@ -401,7 +401,7 @@ private fun RenameDialog( var text by remember(tag.id) { mutableStateOf(tag.name) } val trimmed = text.trim() val clash = - others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) } + others.firstOrNull { it.id != tag.id && foldTag(it.name) == foldTag(trimmed) } val submit = { when { trimmed.isEmpty() -> Unit diff --git a/frontend/src/components/LabelPicker.vue b/frontend/src/components/LabelPicker.vue index d91ed32..9848356 100644 --- a/frontend/src/components/LabelPicker.vue +++ b/frontend/src/components/LabelPicker.vue @@ -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) { diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index a396c0d..76188c0 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -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>(() => { const map: Record = {}; - 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; }); diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 12930b2..a6d61b8 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -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 = { /** * 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 { - 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 diff --git a/frontend/src/notes/tags.ts b/frontend/src/notes/tags.ts new file mode 100644 index 0000000..7fa64b2 --- /dev/null +++ b/frontend/src/notes/tags.ts @@ -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(); +} diff --git a/frontend/src/stores/labels.ts b/frontend/src/stores/labels.ts index ece0494..846d40e 100644 --- a/frontend/src/stores/labels.ts +++ b/frontend/src/stores/labels.ts @@ -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