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
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:
@@ -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.
|
* 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.
|
* 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
|
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
|
||||||
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
|
* colour. [foldTag] and the web's are both locale-independent lowercasing, so the
|
||||||
* `toLowerCase()` are both locale-independent, so the mirror holds.
|
* mirror holds.
|
||||||
*/
|
*/
|
||||||
fun resolvedLabelColor(
|
fun resolvedLabelColor(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -98,5 +98,12 @@ fun resolvedLabelColor(
|
|||||||
when {
|
when {
|
||||||
color.isNotEmpty() && color != "default" && color in known -> color
|
color.isNotEmpty() && color != "default" && color in known -> color
|
||||||
name.isEmpty() -> "default"
|
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()
|
||||||
|
|||||||
@@ -366,11 +366,11 @@ private fun tintTags(
|
|||||||
// A tag the note does not carry as a label yet — just typed, not yet
|
// 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
|
// derived — still gets a colour: `labelTint` falls back to deriving one
|
||||||
// from the name, which is what the chip would have shown anyway.
|
// 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 {
|
buildAnnotatedString {
|
||||||
append(text)
|
append(text)
|
||||||
spans.forEach { tag ->
|
spans.forEach { tag ->
|
||||||
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
|
val tint = labelTint(tag.name, picked[foldTag(tag.name)].orEmpty())
|
||||||
addStyle(
|
addStyle(
|
||||||
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
||||||
tag.start.toInt(),
|
tag.start.toInt(),
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ private fun RenameDialog(
|
|||||||
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||||
val trimmed = text.trim()
|
val trimmed = text.trim()
|
||||||
val clash =
|
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 = {
|
val submit = {
|
||||||
when {
|
when {
|
||||||
trimmed.isEmpty() -> Unit
|
trimmed.isEmpty() -> Unit
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|||||||
import { useLabelsStore, type Label } from "../stores/labels";
|
import { useLabelsStore, type Label } from "../stores/labels";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import type { NoteLabel } from "../stores/notes";
|
import type { NoteLabel } from "../stores/notes";
|
||||||
|
import { foldTag } from "../notes/tags";
|
||||||
|
|
||||||
// A label attached via the picker is manual (via_tag: false).
|
// 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 });
|
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 canCreate = computed(() => {
|
||||||
const name = filter.value.trim();
|
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) {
|
function toggle(lb: Label) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { formatShortDateTime, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
import { formatShortDateTime, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import { canEditText, isOwnNote } from "../notes/sharing";
|
import { canEditText, isOwnNote } from "../notes/sharing";
|
||||||
|
import { foldTag } from "../notes/tags";
|
||||||
|
|
||||||
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -200,12 +201,12 @@ watch(
|
|||||||
// through the picker.
|
// through the picker.
|
||||||
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
|
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.
|
// 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 tagColors = computed<Record<string, string>>(() => {
|
||||||
const map: 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;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// here (frontend concern). Class strings are full literals so Tailwind's content
|
// here (frontend concern). Class strings are full literals so Tailwind's content
|
||||||
// scanner (src/**/*.ts) keeps them in the build.
|
// scanner (src/**/*.ts) keeps them in the build.
|
||||||
|
|
||||||
|
import { foldTag } from "./tags";
|
||||||
|
|
||||||
export const NOTE_COLOR_KEYS = [
|
export const NOTE_COLOR_KEYS = [
|
||||||
"default",
|
"default",
|
||||||
"red",
|
"red",
|
||||||
@@ -82,13 +84,13 @@ export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
|
|||||||
/**
|
/**
|
||||||
* The classes for one `#tag` in a note's own words.
|
* 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
|
* 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
|
* 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.
|
* `resolveLabelColor` derives one from the name exactly as the chip would have.
|
||||||
*/
|
*/
|
||||||
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
|
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
|
* An explicitly-picked colour is still stored and still wins, so tag colours stay
|
||||||
* editable exactly as asked.
|
* editable exactly as asked.
|
||||||
*
|
*
|
||||||
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
|
||||||
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
|
* colour. `foldTag` here and Android's are both locale-independent lowercasing, so
|
||||||
* `lowercase()` are locale-independent, so the mirror holds.
|
* the mirror holds.
|
||||||
*/
|
*/
|
||||||
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
|
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
|
||||||
const picked = label.color as NoteColor | undefined | null;
|
const picked = label.color as NoteColor | undefined | null;
|
||||||
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
||||||
if (!label.name) return "default";
|
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
|
// The fixture (four UUID hashes and seven tag names) lives in
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { repo } from "../adapters";
|
import { repo } from "../adapters";
|
||||||
|
import { foldTag } from "../notes/tags";
|
||||||
|
|
||||||
export interface Label {
|
export interface Label {
|
||||||
id: string;
|
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
|
// 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.
|
// one we asked to rename, and an id that still matches proves nothing happened.
|
||||||
const absorbing = items.value.find(
|
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) {
|
if (absorbing) {
|
||||||
// A merge cannot be undone by repeating it, and here it is reachable by a
|
// A merge cannot be undone by repeating it, and here it is reachable by a
|
||||||
|
|||||||
Reference in New Issue
Block a user