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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { 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
|
||||
|
||||
Reference in New Issue
Block a user