Repository: blacksmithgu/obsidian-dataview
Pull request #2272 (2024-03-17 19:05:08)

feat: hash() - Generate a unique number based on input
Based on a unique combination of a seed, text and a variant number, it generates a fixed number which can be used for sorting in a random order. Typical seed could be the date of today, the text a file name, and the variant could be the line number of a list/task item.

As discussed in #2252 this is the alternate PR just having a `hash()` function, which is a simple logic, but still provides the needs necessary for sorting any list in a random order, but with fixed value for a given input sequence. 

Given a date as the seed, this can be used to sort lists of files/tasks/lists, and then return a random item of that list, and the item for that given date will remain the same (as long as the other parameters stay the same). This deals with an issue of using _dataview_ to receive random items, where it'll change for every refresh of the random query.

See also https://jsperf.app/bowuze for the reasoning why this particular hash function was used.


holroy: Attached here is a self-contained test file (which I added to my local test-vault in the `untracked` folder):

[Testing hash().md](https://github.com/blacksmithgu/obsidian-dataview/files/14628576/Testing.hash.md)

GottZ: you are aware this is conflicting with #2271 due to the lint fix?
holroy: Why do you think it's in conflict with #2271?
GottZ: > Why do you think it's in conflict with #2271? Please explain your reasoning.
> 
> Even though the same fix has been made in both versions, they should cancel each other out when properly merged which is why this PR also has all of its checked passed.

@blacksmithgu usually does squash merges.
after one is merged, a conflict is likely to happen.
either he resolves it then, or you pull master and resolve it in your branch yourself.
we will see.
if it does come to a conflict, it's pretty easy to solve.
blacksmithgu: I can resolve both merge conflicts.

Patch:
diff --git a/docs/docs/reference/functions.md b/docs/docs/reference/functions.md
index 310f880..ae3ff61 100644
--- a/docs/docs/reference/functions.md
+++ b/docs/docs/reference/functions.md
@@ -700,6 +700,25 @@ choice(false, "yes", "no") = "no"
 choice(x > 4, y, z) = y if x > 4, else z
 ```
 
+### `hash(seed, [text], [variant])`
+
+Generate a hash based on the `seed`, and the optional extra `text` or a variant `number`. The function
+generates a fixed number based on the combination of these parameters, which can be used to randomise 
+the sort order of files or lists/tasks. If you choose a `seed` based on a date, i.e. "2024-03-17",
+or another timestamp, i.e. "2024-03-17 19:13", you can make the "randomness" be fixed
+related to that timestamp. `variant` is a number, which in some cases is needed to make the combination of
+`text` and `variant` become unique.
+
+```js
+hash(dateformat(date(today), "YYYY-MM-DD"), file.name) = ... A unique value for a given date in time
+hash(dateformat(date(today), "YYYY-MM-DD"), file.name, position.start.line) = ... A unique "random" value in a TASK query
+```
+
+This function can be used in a `SORT` statement to randomise the order. If you're using a `TASK` query, 
+since the file name could be the same for multiple tasks, you can add some number like the starting line
+number (as shown above) to make it a unique combination. If using something like `FLATTEN file.lists as item`, 
+the similar addition would be to do `item.position.start.line` as the last parameter.
+
 ### `striptime(date)`
 
 Strip the time component of a date, leaving only the year, month, and day. Good for date comparisons if you don't care
diff --git a/src/expression/functions.ts b/src/expression/functions.ts
index 7bd5c5d..95c5efd 100644
--- a/src/expression/functions.ts
+++ b/src/expression/functions.ts
@@ -9,6 +9,7 @@ import { Fields } from "./field";
 import { EXPRESSION } from "./parse";
 import { escapeRegex } from "util/normalize";
 import { DataArray } from "api/data-array";
+import { cyrb53 } from "util/hash";
 
 /**
  * A function implementation which takes in a function context and a variable number of arguments. Throws an error if an
@@ -704,6 +705,18 @@ export namespace DefaultFunctions {
         .vectorize(3, [0])
         .build();
 
+    export const hash = new FunctionBuilder("hash")
+        .add2("string", "number", (seed, variant) => {
+            return cyrb53(seed, variant);
+        })
+        .add2("string", "string", (seed, text) => {
+            return cyrb53(seed + text);
+        })
+        .add3("string", "string", "number", (seed, text, variant) => {
+            return cyrb53(seed + text, variant);
+        })
+        .build();
+
     export const reduce = new FunctionBuilder("reduce")
         .add2("array", "string", (lis, op, context) => {
             if (lis.length == 0) return null;
@@ -924,5 +937,6 @@ export const DEFAULT_FUNCTIONS: Record<string, FunctionImpl> = {
     default: DefaultFunctions.fdefault,
     ldefault: DefaultFunctions.ldefault,
     choice: DefaultFunctions.choice,
+    hash: DefaultFunctions.hash,
     meta: DefaultFunctions.meta,
 };
diff --git a/src/ui/render.ts b/src/ui/render.ts
index 8920177..3e1667a 100644
--- a/src/ui/render.ts
+++ b/src/ui/render.ts
@@ -88,7 +88,14 @@ export async function renderValue(
     }
 
     if (Values.isNull(field)) {
-        await renderCompactMarkdown(app, settings.renderNullAs, container, originFile, component, isInlineFieldLivePreview);
+        await renderCompactMarkdown(
+            app,
+            settings.renderNullAs,
+            container,
+            originFile,
+            component,
+            isInlineFieldLivePreview
+        );
     } else if (Values.isDate(field)) {
         container.appendText(renderMinimalDate(field, settings, currentLocale()));
     } else if (Values.isDuration(field)) {
diff --git a/src/util/hash.ts b/src/util/hash.ts
new file mode 100644
index 0000000..83ab5fd
--- /dev/null
+++ b/src/util/hash.ts
@@ -0,0 +1,22 @@
+// cyrb53 (c) 2018 bryc (github.com/bryc). License: Public domain. Attribution appreciated.
+// A fast and simple 64-bit (or 53-bit) string hash function with decent collision resistance.
+// Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
+// See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript/52171480#52171480
+// https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
+
+export function cyrb53(str: string, seed: number = 0): number {
+    let h1 = 0xdeadbeef ^ seed,
+        h2 = 0x41c6ce57 ^ seed;
+    for (let i = 0, ch; i < str.length; i++) {
+        ch = str.charCodeAt(i);
+        h1 = Math.imul(h1 ^ ch, 2654435761);
+        h2 = Math.imul(h2 ^ ch, 1597334677);
+    }
+    h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+    h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+    h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+    h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+    // For a full 64-bit value we could return
+    //  [h2>>>0, h1>>>0]
+    return 4294967296 * (2097151 & h2) + (h1 >>> 0); // ;
+}


Test patch:
diff --git a/src/test/function/functions.test.ts b/src/test/function/functions.test.ts
index fc35851..ab365a3 100644
--- a/src/test/function/functions.test.ts
+++ b/src/test/function/functions.test.ts
@@ -124,6 +124,13 @@ test("Evaluate choose()", () => {
     expect(parseEval("choice(false, 1, 2)")).toEqual(2);
 });
 
+test("Evaulate hash()", () => {
+    expect(DefaultFunctions.hash(simpleContext(), "2024-03-17", "")).toEqual(3259376374957153);
+    expect(DefaultFunctions.hash(simpleContext(), "2024-03-17", 2)).toEqual(271608741894590);
+    expect(DefaultFunctions.hash(simpleContext(), "2024-03-17", "Home")).toEqual(3041844187830523);
+    expect(DefaultFunctions.hash(simpleContext(), "2024-03-17", "note a1", 21)).toEqual(1143088188331616);
+});
+
 // <-- extract() -->
 
 test("Evaluate 1 field extract()", () => {
diff --git a/test-vault/.obsidian/community-plugins.json b/test-vault/.obsidian/community-plugins.json
index f51d89b..a832eab 100644
--- a/test-vault/.obsidian/community-plugins.json
+++ b/test-vault/.obsidian/community-plugins.json
@@ -1,4 +1,4 @@
 [
-  "dataview",
-  "hot-reload"
+  "hot-reload",
+  "dataview"
 ]
\ No newline at end of file