From 21fc386317d8bc41f5bdbf416c17263a4f7e055d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 10 Jun 2021 11:40:10 +0100 Subject: [PATCH] Move over to new lexicographic string sorting --- src/stores/SpaceStore.tsx | 65 +++++++++++++++---------- src/utils/stringOrderField.ts | 56 ++++++++++++++++++++++ test/utils/stringOrderField-test.ts | 73 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 src/utils/stringOrderField.ts create mode 100644 test/utils/stringOrderField-test.ts diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 5e09b617a7..47c735285c 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -34,6 +34,12 @@ import {setHasDiff} from "../utils/sets"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; import { arrayHasOrderChange } from "../utils/arrays"; +import { + ALPHABET_END, + ALPHABET_START, + averageBetweenStrings, + midPointsBetweenStrings, +} from "../utils/stringOrderField"; interface IState {} @@ -61,18 +67,19 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, }, [[], []]); }; -// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id` -export const getChildOrder = (order: string, creationTs: number, roomId: string): Array>> => { - let validatedOrder: string = null; - - if (typeof order === "string" && Array.from(order).every((c: string) => { +const validOrder = (order: string): string | null => { + if (typeof order === "string" && order.length <= 50 && Array.from(order).every((c: string) => { const charCode = c.charCodeAt(0); return charCode >= 0x20 && charCode <= 0x7E; })) { - validatedOrder = order; + return order; } + return undefined; +}; - return [validatedOrder, creationTs, roomId]; +// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id` +export const getChildOrder = (order: string, creationTs: number, roomId: string): Array>> => { + return [validOrder(order), creationTs, roomId]; } const getRoomFn: FetchRoomFn = (room: Room) => { @@ -625,8 +632,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private getSpaceTagOrdering = (space: Room): string | undefined => { if (this.spaceOrderLocalEchoMap.has(space.roomId)) return this.spaceOrderLocalEchoMap.get(space.roomId); - const order = space.getAccountData(EventType.SpaceOrder)?.getContent()?.order; - return typeof order === "string" ? order : undefined; + return validOrder(space.getAccountData(EventType.SpaceOrder)?.getContent()?.order); }; private sortRootSpaces(spaces: Room[]): Room[] { @@ -635,7 +641,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private setRootSpaceOrder(space: Room, order: string): void { this.spaceOrderLocalEchoMap.set(space.roomId, order); - this.matrixClient.setRoomAccountData(space.roomId, EventType.SpaceOrder, { order }); + this.matrixClient.setRoomAccountData(space.roomId, EventType.SpaceOrder, { order }); // TODO retrying, failure } public moveRootSpace(fromIndex: number, toIndex: number): void { @@ -653,32 +659,42 @@ export class SpaceStoreClass extends AsyncStoreWithClient { let nextOrder: string; if (toIndex > fromIndex) { - prevOrder = toIndex >= 0 ? orders[toIndex] : "aaaaa"; - nextOrder = toIndex <= orders.length ? orders[toIndex + 1] : "zzzzz"; + // moving down + prevOrder = orders[toIndex]; + nextOrder = orders[toIndex + 1]; } else { // accounts for downwards displacement of existing inhabitant of this index - prevOrder = toIndex > 0 ? orders[toIndex - 1] : "aaaaa"; - nextOrder = toIndex < orders.length ? orders[toIndex] : "zzzzz"; + prevOrder = toIndex > 0 ? orders[toIndex - 1] : String.fromCharCode(ALPHABET_START).repeat(5); // TODO + nextOrder = orders[toIndex]; } console.log("@@ start", {fromIndex, toIndex, orders, prevOrder, nextOrder}); if (prevOrder === undefined) { + // to be able to move to this toIndex we will first need to insert a bunch of orders for earlier elements const firstUndefinedIndex = orders.indexOf(undefined); const numUndefined = orders.length - firstUndefinedIndex; - const lastOrder = orders[firstUndefinedIndex - 1]; - console.log("@@ precalc", {firstUndefinedIndex, numUndefined, lastOrder}); - nextOrder = lastOrder + step; - for (let i = firstUndefinedIndex; i < toIndex; i++, nextOrder += step) { - console.log("@@ preset", {i, nextOrder}); - this.setRootSpaceOrder(this.rootSpaces[i], nextOrder); - } + const lastOrder = orders[firstUndefinedIndex - 1] ?? String.fromCharCode(ALPHABET_START); // TODO + nextOrder = String.fromCharCode(ALPHABET_END).repeat(lastOrder.length + 1); + const newOrders = midPointsBetweenStrings(lastOrder, nextOrder, numUndefined); - prevOrder = nextOrder; - nextOrder += step; + if (newOrders.length === numUndefined) { + console.log("@@ precalc", {firstUndefinedIndex, numUndefined, lastOrder, newOrders}); + for (let i = firstUndefinedIndex, j = 0; i <= toIndex; i++, j++) { + if (i === toIndex && toIndex < fromIndex) continue; + if (i === fromIndex) continue; + const newOrder = newOrders[j]; + console.log("@@ preset", {i, j, newOrder}); + this.setRootSpaceOrder(this.rootSpaces[i], newOrder); + } + + prevOrder = newOrders[newOrders.length - 1]; + } else { + prevOrder = nextOrder; // rebuild + } } if (prevOrder !== nextOrder) { - const order = prevOrder + ((nextOrder - prevOrder) / 2); + const order = averageBetweenStrings(prevOrder, nextOrder ?? String.fromCharCode(ALPHABET_END).repeat(prevOrder.length + 1)); console.log("@@ set", {prevOrder, nextOrder, order}); this.setRootSpaceOrder(space, order); } else { @@ -686,6 +702,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } this.notifyIfOrderChanged(); + console.log("@@ done", this.rootSpaces.map(this.getSpaceTagOrdering)); } } diff --git a/src/utils/stringOrderField.ts b/src/utils/stringOrderField.ts new file mode 100644 index 0000000000..fce859ddb8 --- /dev/null +++ b/src/utils/stringOrderField.ts @@ -0,0 +1,56 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export const ALPHABET_START = 0x20; +export const ALPHABET_END = 0x7E; +export const ALPHABET = new Array(1 + ALPHABET_END - ALPHABET_START) + .fill(undefined) + .map((_, i) => String.fromCharCode(ALPHABET_START + i)) + .join(""); + +export const baseToString = (base: number, alphabet = ALPHABET): string => { + base = Math.floor(base); + if (base < alphabet.length) return alphabet[base]; + return baseToString(Math.floor(base / alphabet.length), alphabet) + alphabet[base % alphabet.length]; +}; + +export const stringToBase = (str: string, alphabet = ALPHABET): number => { + let result = 0; + for (let i = str.length - 1, j = 0; i >= 0; i--, j++) { + result += (str.charCodeAt(i) - alphabet.charCodeAt(0)) * (alphabet.length ** j); + } + return result; +}; + +const pad = (str: string, length: number, alphabet = ALPHABET): string => str.padEnd(length, alphabet[0]); + +export const averageBetweenStrings = (a: string, b: string, alphabet = ALPHABET): string => { + const n = Math.max(a.length, b.length); + const aBase = stringToBase(pad(a, n, alphabet), alphabet); + const bBase = stringToBase(pad(b, n, alphabet), alphabet); + return baseToString((aBase + bBase) / 2, alphabet); +}; + +export const midPointsBetweenStrings = (a: string, b: string, count: number, alphabet = ALPHABET): string[] => { + const n = Math.max(a.length, b.length); + const aBase = stringToBase(pad(a, n, alphabet), alphabet); + const bBase = stringToBase(pad(b, n, alphabet), alphabet); + const step = (bBase - aBase) / (count + 1); + if (step < 1) { + return []; + } + return Array(count).fill(undefined).map((_, i) => baseToString(aBase + step + (i * step), alphabet)); +}; diff --git a/test/utils/stringOrderField-test.ts b/test/utils/stringOrderField-test.ts new file mode 100644 index 0000000000..5b8c2f3feb --- /dev/null +++ b/test/utils/stringOrderField-test.ts @@ -0,0 +1,73 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + ALPHABET, + averageBetweenStrings, + baseToString, + midPointsBetweenStrings, + stringToBase, +} from "../../src/utils/stringOrderField"; + +describe("stringOrderField", () => { + it("stringToBase", () => { + expect(stringToBase(" ")).toBe(0); + expect(stringToBase("a")).toBe(65); + expect(stringToBase("aa")).toBe(6240); + expect(stringToBase("cat")).toBe(610934); + expect(stringToBase("doggo")).toBe(5607022724); + expect(stringToBase(" ")).toEqual(0); + expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(0); + expect(stringToBase("a")).toEqual(65); + expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(2); + expect(stringToBase("ab")).toEqual(6241); + expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(53); + }); + + it("baseToString", () => { + expect(baseToString(10)).toBe(ALPHABET[10]); + expect(baseToString(10, "abcdefghijklmnopqrstuvwxyz")).toEqual("k"); + expect(baseToString(6241)).toEqual("ab"); + expect(baseToString(53, "abcdefghijklmnopqrstuvwxyz")).toEqual("cb"); + expect(baseToString(1234)).toBe(",~"); + }); + + it("averageBetweenStrings", () => { + [ + { a: "a", b: "z", output: `m` }, + { a: "ba", b: "z", output: `n@` }, + { a: "z", b: "ba", output: `n@` }, + { a: "# ", b: "$8888", output: `#[[[[` }, + { a: "cat", b: "doggo", output: `d9>Cw` }, + { a: "cat", b: "doggo", output: "cumqh", alphabet: "abcdefghijklmnopqrstuvwxyz" }, + { a: "aa", b: "zz", output: "mz", alphabet: "abcdefghijklmnopqrstuvwxyz" }, + { a: "a", b: "z", output: "m", alphabet: "abcdefghijklmnopqrstuvwxyz" }, + { a: "AA", b: "zz", output: "^." }, + { a: "A", b: "z", output: "]" }, + ].forEach((c) => { + // assert that the output string falls lexicographically between `a` and `b` + expect([c.a, c.b, c.output].sort()[1]).toBe(c.output); + expect(averageBetweenStrings(c.a, c.b, c.alphabet)).toBe(c.output); + }); + }); + + it("midPointsBetweenStrings", () => { + expect(midPointsBetweenStrings("a", "e", 3)).toStrictEqual(["b", "c", "d"]); + expect(midPointsBetweenStrings("a", "e", 0)).toStrictEqual([]); + expect(midPointsBetweenStrings("a", "e", 4)).toStrictEqual([]); + }); +}); +