mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-17 02:09:44 -07:00
global: add cohorts by entry
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../modules
|
||||
@@ -1,14 +0,0 @@
|
||||
LICENSE
|
||||
**/*.*.*/*.json
|
||||
*webcomponent*
|
||||
cli*
|
||||
extras/
|
||||
*.cjs
|
||||
dev.js
|
||||
*.development*
|
||||
*.iife.*
|
||||
nano.*
|
||||
worker.*
|
||||
*.mts
|
||||
*.cts
|
||||
*.rs
|
||||
@@ -1 +0,0 @@
|
||||
generated
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"bugs": {
|
||||
"url": "https://github.com/bitcoinresearchkit/brk/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node tests/basic.js && node tests/tree.js",
|
||||
"test:basic": "node tests/basic.js",
|
||||
"test:tree": "node tests/tree.js"
|
||||
},
|
||||
"description": "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"generated"
|
||||
],
|
||||
"homepage": "https://github.com/bitcoinresearchkit/brk/tree/main/modules/brk-client",
|
||||
"keywords": [
|
||||
"brk",
|
||||
"bitcoin",
|
||||
"blockchain",
|
||||
"research",
|
||||
"on-chain",
|
||||
"analytics",
|
||||
"metrics",
|
||||
"api",
|
||||
"data",
|
||||
"cryptocurrency"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"name": "brk-client",
|
||||
"repository": {
|
||||
"directory": "modules/brk-client",
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
||||
},
|
||||
"type": "module",
|
||||
"version": "0.3.1"
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { BrkClient } from "../index.js";
|
||||
|
||||
const client = new BrkClient("http://localhost:3110");
|
||||
|
||||
console.log("Testing idiomatic API...\n");
|
||||
|
||||
// Test getter access (property)
|
||||
console.log("1. Getter access (.by.dateindex):");
|
||||
const all = await client.series.prices.split.close.usd.by.day1;
|
||||
console.log(` Got: ${all.data.length} items\n`);
|
||||
|
||||
// Test dynamic access (bracket notation)
|
||||
console.log("2. Dynamic access (.by['dateindex']):");
|
||||
const allDynamic = await client.series.prices.split.close.usd.by.day1;
|
||||
console.log(` Got: ${allDynamic.data.length} items\n`);
|
||||
|
||||
// Test fetch all (explicit .fetch())
|
||||
console.log("3. Explicit .fetch():");
|
||||
const allExplicit = await client.series.prices.split.close.usd.by.day1.fetch();
|
||||
console.log(` Got: ${allExplicit.data.length} items\n`);
|
||||
|
||||
// Test first(n)
|
||||
console.log("4. First 5 items (.first(5)):");
|
||||
const first5 = await client.series.prices.split.close.usd.by.day1.first(5);
|
||||
console.log(
|
||||
` Start: ${first5.start}, End: ${first5.end}, Got: ${first5.data.length} items\n`,
|
||||
);
|
||||
|
||||
// Test last(n)
|
||||
console.log("5. Last 5 items (.last(5)):");
|
||||
const last5 = await client.series.prices.split.close.usd.by.day1.last(5);
|
||||
console.log(
|
||||
` Start: ${last5.start}, End: ${last5.end}, Got: ${last5.data.length} items\n`,
|
||||
);
|
||||
|
||||
// Test slice(start, end)
|
||||
console.log("6. Slice 10-20 (.slice(10, 20)):");
|
||||
const sliced = await client.series.prices.split.close.usd.by.day1.slice(10, 20);
|
||||
console.log(
|
||||
` Start: ${sliced.start}, End: ${sliced.end}, Got: ${sliced.data.length} items\n`,
|
||||
);
|
||||
|
||||
// Test get(index) - single item
|
||||
console.log("7. Single item (.get(100)):");
|
||||
const single = await client.series.prices.split.close.usd.by.day1.get(100);
|
||||
console.log(
|
||||
` Start: ${single.start}, End: ${single.end}, Got: ${single.data.length} item(s)\n`,
|
||||
);
|
||||
|
||||
// Test skip(n).take(m) chaining
|
||||
console.log("8. Skip and take (.skip(100).take(10)):");
|
||||
const skipTake = await client.series.prices.split.close.usd.by.day1
|
||||
.skip(100)
|
||||
.take(10);
|
||||
console.log(
|
||||
` Start: ${skipTake.start}, End: ${skipTake.end}, Got: ${skipTake.data.length} items\n`,
|
||||
);
|
||||
|
||||
// Test fetchCsv
|
||||
console.log("9. Fetch as CSV (.last(3).fetchCsv()):");
|
||||
const csv = await client.series.prices.split.close.usd.by.day1
|
||||
.last(3)
|
||||
.fetchCsv();
|
||||
console.log(` CSV preview: ${csv.substring(0, 100)}...\n`);
|
||||
|
||||
console.log("All tests passed!");
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Consistency test: verifies that all series sharing the same index have the same length.
|
||||
* Useful for catching stale/inconsistent state after a reorg rollback.
|
||||
*/
|
||||
|
||||
import { BrkClient } from "../index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('../index.js').AnySeriesPattern} AnyMetricPattern
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {any} obj
|
||||
* @returns {obj is AnyMetricPattern}
|
||||
*/
|
||||
function isMetricPattern(obj) {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === "object" &&
|
||||
typeof obj.indexes === "function" &&
|
||||
obj.by &&
|
||||
typeof obj.by === "object"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all metric patterns from the tree.
|
||||
* @param {Record<string, any>} obj
|
||||
* @param {string} path
|
||||
* @returns {Array<{path: string, metric: AnyMetricPattern}>}
|
||||
*/
|
||||
function getAllMetrics(obj, path = "") {
|
||||
/** @type {Array<{path: string, metric: AnyMetricPattern}>} */
|
||||
const metrics = [];
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const attr = obj[key];
|
||||
if (!attr || typeof attr !== "object") continue;
|
||||
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (isMetricPattern(attr)) {
|
||||
metrics.push({ path: currentPath, metric: attr });
|
||||
}
|
||||
|
||||
if (typeof attr === "object" && !Array.isArray(attr)) {
|
||||
metrics.push(...getAllMetrics(attr, currentPath));
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function testConsistency() {
|
||||
const client = new BrkClient({
|
||||
baseUrl: "http://localhost:3110",
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const metrics = getAllMetrics(client.series);
|
||||
console.log(`\nFound ${metrics.length} metrics`);
|
||||
|
||||
/** @type {Map<string, Array<{path: string, total: number}>>} */
|
||||
const byIndex = new Map();
|
||||
|
||||
for (const { path, metric } of metrics) {
|
||||
const indexes = metric.indexes();
|
||||
|
||||
for (const idxName of indexes) {
|
||||
const fullPath = `${path}.by.${idxName}`;
|
||||
const endpoint = metric.by[idxName];
|
||||
|
||||
if (!endpoint) {
|
||||
console.log(`SKIP: ${fullPath} (undefined endpoint)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await endpoint.last(0);
|
||||
const total = result.end;
|
||||
|
||||
if (!byIndex.has(idxName)) {
|
||||
byIndex.set(idxName, []);
|
||||
}
|
||||
/** @type {Array<{path: string, total: number}>} */ (byIndex.get(idxName)).push({ path: fullPath, total });
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`FAIL: ${fullPath} -> ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const [index, entries] of byIndex) {
|
||||
const totals = new Set(entries.map((e) => e.total));
|
||||
|
||||
if (totals.size === 1) {
|
||||
const [total] = totals;
|
||||
console.log(`OK: ${index} — ${entries.length} series, all length ${total}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
failed = true;
|
||||
console.log(`\nMISMATCH: ${index} — ${entries.length} series with ${totals.size} different lengths:`);
|
||||
|
||||
/** @type {Map<number, string[]>} */
|
||||
const grouped = new Map();
|
||||
for (const { path, total } of entries) {
|
||||
if (!grouped.has(total)) grouped.set(total, []);
|
||||
/** @type {string[]} */ (grouped.get(total)).push(path);
|
||||
}
|
||||
|
||||
for (const [total, paths] of [...grouped].sort((a, b) => b[0] - a[0])) {
|
||||
console.log(` length ${total}: (${paths.length} series)`);
|
||||
for (const p of paths) {
|
||||
console.log(` ${p}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.log("\nFAILED: length mismatches detected");
|
||||
throw new Error("length mismatches detected");
|
||||
} else {
|
||||
console.log("\nPASSED: all indexes consistent");
|
||||
}
|
||||
}
|
||||
|
||||
testConsistency();
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Tests for MetricData helper methods and date conversion functions.
|
||||
* Run: node tests/metric_data.js
|
||||
*/
|
||||
|
||||
import { BrkClient } from "../index.js";
|
||||
|
||||
const client = new BrkClient("http://localhost:3110");
|
||||
|
||||
console.log("Testing MetricData helpers...\n");
|
||||
|
||||
// Fetch a date-based metric
|
||||
console.log("1. Fetching price data (day1):");
|
||||
const price = await client.series.prices.split.close.usd.by.day1.first(5);
|
||||
console.log(` Start: ${price.start}, End: ${price.end}`);
|
||||
|
||||
// Test isDateBased
|
||||
console.log("\n2. isDateBased:");
|
||||
if (!price.isDateBased) throw new Error("day1 should be date-based");
|
||||
console.log(` day1: ${price.isDateBased}`);
|
||||
|
||||
// Test indexes() - always returns numbers
|
||||
console.log("\n3. indexes():");
|
||||
const indexes = price.indexes();
|
||||
console.log(` ${JSON.stringify(indexes)}`);
|
||||
if (indexes.length !== 5) throw new Error("Expected 5 indexes");
|
||||
if (indexes[0] !== price.start)
|
||||
throw new Error("First index should equal start");
|
||||
|
||||
// Test dates() - DateMetricData method
|
||||
console.log("\n4. dates():");
|
||||
const dates = price.dates();
|
||||
console.log(
|
||||
` First: ${dates[0].toISOString()}, Last: ${dates[dates.length - 1].toISOString()}`,
|
||||
);
|
||||
if (dates.length !== 5) throw new Error("Expected 5 dates");
|
||||
// DateIndex 0 = Jan 3, 2009 (genesis)
|
||||
if (
|
||||
dates[0].getFullYear() !== 2009 ||
|
||||
dates[0].getMonth() !== 0 ||
|
||||
dates[0].getDate() !== 3
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected genesis date (2009-01-03), got ${dates[0].toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test keys() - always returns numbers (alias for indexes)
|
||||
console.log("\n5. keys():");
|
||||
const keys = price.keys();
|
||||
if (keys.length !== 5) throw new Error("Expected 5 keys");
|
||||
if (typeof keys[0] !== "number") throw new Error("Expected number keys");
|
||||
console.log(` Length: ${keys.length}, First: ${keys[0]}`);
|
||||
|
||||
// Test entries() - returns [number, value] pairs
|
||||
console.log("\n6. entries():");
|
||||
const entries = price.entries();
|
||||
if (typeof entries[0][0] !== "number")
|
||||
throw new Error("Expected number entry key");
|
||||
console.log(` First: [${entries[0][0]}, ${entries[0][1]}]`);
|
||||
if (entries[0][1] !== price.data[0])
|
||||
throw new Error("First entry value mismatch");
|
||||
|
||||
// Test dateEntries() - DateMetricData method, returns [Date, value] pairs
|
||||
console.log("\n7. dateEntries():");
|
||||
const dateEntries = price.dateEntries();
|
||||
if (!(dateEntries[0][0] instanceof Date))
|
||||
throw new Error("Expected Date entry key");
|
||||
console.log(
|
||||
` First: [${dateEntries[0][0].toISOString()}, ${dateEntries[0][1]}]`,
|
||||
);
|
||||
|
||||
// Test toMap() - returns Map<number, value>
|
||||
console.log("\n8. toMap():");
|
||||
const map = price.toMap();
|
||||
console.log(` Size: ${map.size}`);
|
||||
if (map.size !== 5) throw new Error("Expected map size 5");
|
||||
|
||||
// Test toDateMap() - DateMetricData method
|
||||
console.log("\n9. toDateMap():");
|
||||
const dateMap = price.toDateMap();
|
||||
console.log(` Size: ${dateMap.size}`);
|
||||
if (dateMap.size !== 5) throw new Error("Expected date map size 5");
|
||||
|
||||
// Test Symbol.iterator (for...of) - yields [number, value]
|
||||
console.log("\n10. for...of iteration:");
|
||||
let count = 0;
|
||||
for (const [key, _val] of price) {
|
||||
if (count === 0 && typeof key !== "number")
|
||||
throw new Error("Expected number keys in iteration");
|
||||
count++;
|
||||
}
|
||||
console.log(` Iterated ${count} items`);
|
||||
if (count !== 5) throw new Error("Expected 5 iterations");
|
||||
|
||||
// Test with non-date-based index (height)
|
||||
console.log("\n11. Testing height-based metric:");
|
||||
const heightMetric = await client.series.prices.spot.usd.by.height.last(3);
|
||||
console.log(` Start: ${heightMetric.start}, End: ${heightMetric.end}`);
|
||||
if (heightMetric.isDateBased)
|
||||
throw new Error("height should not be date-based");
|
||||
|
||||
// Test keys() - always numbers
|
||||
const heightKeys = heightMetric.keys();
|
||||
console.log(` keys(): ${JSON.stringify(heightKeys)}`);
|
||||
if (typeof heightKeys[0] !== "number")
|
||||
throw new Error("Expected number keys for height");
|
||||
|
||||
// Test entries() - [number, value]
|
||||
const heightEntries = heightMetric.entries();
|
||||
console.log(
|
||||
` entries()[0]: [${heightEntries[0][0]}, ${heightEntries[0][1]}]`,
|
||||
);
|
||||
if (heightEntries[0][0] !== heightMetric.start)
|
||||
throw new Error("First entry index mismatch");
|
||||
|
||||
// Test toMap() - Map<number, value>
|
||||
const heightMap = heightMetric.toMap();
|
||||
if (heightMap.size !== 3) throw new Error("Expected map size 3");
|
||||
if (heightMap.get(heightMetric.start) !== heightMetric.data[0])
|
||||
throw new Error("First value mismatch");
|
||||
|
||||
// Test for...of on non-date metric
|
||||
console.log("\n12. for...of iteration (height):");
|
||||
let heightCount = 0;
|
||||
for (const [key, _val] of heightMetric) {
|
||||
if (heightCount === 0 && typeof key !== "number")
|
||||
throw new Error("Expected number keys for height iteration");
|
||||
heightCount++;
|
||||
}
|
||||
console.log(` Iterated ${heightCount} items`);
|
||||
|
||||
// Test different date indexes
|
||||
console.log("\n13. Testing month1:");
|
||||
const monthMetric =
|
||||
await client.series.prices.split.close.usd.by.month1.first(3);
|
||||
const monthDates = monthMetric.dates();
|
||||
console.log(` First month: ${monthDates[0].toISOString()}`);
|
||||
// MonthIndex 0 = Jan 1, 2009
|
||||
if (
|
||||
monthDates[0].getFullYear() !== 2009 ||
|
||||
monthDates[0].getMonth() !== 0 ||
|
||||
monthDates[0].getDate() !== 1
|
||||
) {
|
||||
throw new Error(`Expected 2009-01-01, got ${monthDates[0].toISOString()}`);
|
||||
}
|
||||
|
||||
// Test indexToDate directly
|
||||
console.log("\n14. Testing indexToDate():");
|
||||
const genesis = client.indexToDate("day1", 0);
|
||||
if (
|
||||
genesis.getFullYear() !== 2009 ||
|
||||
genesis.getMonth() !== 0 ||
|
||||
genesis.getDate() !== 3
|
||||
) {
|
||||
throw new Error(`Expected genesis 2009-01-03, got ${genesis.toISOString()}`);
|
||||
}
|
||||
const dayOne = client.indexToDate("day1", 1);
|
||||
if (
|
||||
dayOne.getFullYear() !== 2009 ||
|
||||
dayOne.getMonth() !== 0 ||
|
||||
dayOne.getDate() !== 9
|
||||
) {
|
||||
throw new Error(`Expected day one 2009-01-09, got ${dayOne.toISOString()}`);
|
||||
}
|
||||
console.log(` day1 0: ${genesis.toISOString()}`);
|
||||
console.log(` day1 1: ${dayOne.toISOString()}`);
|
||||
|
||||
// Test week1
|
||||
const week0 = client.indexToDate("week1", 0);
|
||||
const week1 = client.indexToDate("week1", 1);
|
||||
if (week0.getTime() !== genesis.getTime())
|
||||
throw new Error("week1 0 should equal genesis");
|
||||
console.log(` week1 0: ${week0.toISOString()}`);
|
||||
console.log(` week1 1: ${week1.toISOString()}`);
|
||||
|
||||
// Test year1
|
||||
const year0 = client.indexToDate("year1", 0);
|
||||
const year1 = client.indexToDate("year1", 1);
|
||||
if (
|
||||
year0.getFullYear() !== 2009 ||
|
||||
year0.getMonth() !== 0 ||
|
||||
year0.getDate() !== 1
|
||||
) {
|
||||
throw new Error(`Expected 2009-01-01, got ${year0.toISOString()}`);
|
||||
}
|
||||
if (year1.getFullYear() !== 2010) throw new Error("year1 1 should be 2010");
|
||||
console.log(` year1 0: ${year0.toISOString()}`);
|
||||
console.log(` year1 1: ${year1.toISOString()}`);
|
||||
|
||||
// Test month3
|
||||
const q0 = client.indexToDate("month3", 0);
|
||||
const q1 = client.indexToDate("month3", 1);
|
||||
if (q0.getMonth() !== 0) throw new Error("month3 0 should be January");
|
||||
if (q1.getMonth() !== 3) throw new Error("month3 1 should be April");
|
||||
console.log(` month3 0: ${q0.toISOString()}`);
|
||||
console.log(` month3 1: ${q1.toISOString()}`);
|
||||
|
||||
// Test month6
|
||||
const s0 = client.indexToDate("month6", 0);
|
||||
const s1 = client.indexToDate("month6", 1);
|
||||
if (s0.getMonth() !== 0) throw new Error("month6 0 should be January");
|
||||
if (s1.getMonth() !== 6) throw new Error("month6 1 should be July");
|
||||
console.log(` month6 0: ${s0.toISOString()}`);
|
||||
console.log(` month6 1: ${s1.toISOString()}`);
|
||||
|
||||
// Test year10
|
||||
const d0 = client.indexToDate("year10", 0);
|
||||
const d1 = client.indexToDate("year10", 1);
|
||||
if (d0.getFullYear() !== 2009) throw new Error("year10 0 should be 2009");
|
||||
if (d1.getFullYear() !== 2019) throw new Error("year10 1 should be 2019");
|
||||
console.log(` year10 0: ${d0.toISOString()}`);
|
||||
console.log(` year10 1: ${d1.toISOString()}`);
|
||||
|
||||
// Test dateToIndex
|
||||
console.log("\n15. Testing dateToIndex():");
|
||||
const idx = client.dateToIndex("day1", new Date(Date.UTC(2009, 0, 9)));
|
||||
if (idx !== 1) throw new Error(`Expected day1 index 1, got ${idx}`);
|
||||
console.log(` day1 2009-01-09: ${idx}`);
|
||||
|
||||
const monthIdx = client.dateToIndex("month1", new Date(Date.UTC(2010, 0, 1)));
|
||||
if (monthIdx !== 12)
|
||||
throw new Error(`Expected month1 index 12, got ${monthIdx}`);
|
||||
console.log(` month1 2010-01-01: ${monthIdx}`);
|
||||
|
||||
const yearIdx = client.dateToIndex("year1", new Date(Date.UTC(2019, 0, 1)));
|
||||
if (yearIdx !== 10) throw new Error(`Expected year1 index 10, got ${yearIdx}`);
|
||||
console.log(` year1 2019-01-01: ${yearIdx}`);
|
||||
|
||||
// Test roundtrip: indexToDate -> dateToIndex
|
||||
const testDate = client.indexToDate("day1", 100);
|
||||
const roundtrip = client.dateToIndex("day1", testDate);
|
||||
if (roundtrip !== 100)
|
||||
throw new Error(`Roundtrip failed: expected 100, got ${roundtrip}`);
|
||||
console.log(` Roundtrip day1 100: ${testDate.toISOString()} -> ${roundtrip}`);
|
||||
|
||||
// Test slice with Date
|
||||
console.log("\n16. Testing slice with Date:");
|
||||
const dateSlice = await client.series.prices.split.close.usd.by.day1
|
||||
.slice(new Date(Date.UTC(2020, 0, 1)), new Date(Date.UTC(2020, 0, 4)))
|
||||
.fetch();
|
||||
console.log(
|
||||
` Slice start: ${dateSlice.start}, end: ${dateSlice.end}, items: ${dateSlice.data.length}`,
|
||||
);
|
||||
if (dateSlice.data.length !== dateSlice.end - dateSlice.start)
|
||||
throw new Error("Slice data length mismatch");
|
||||
|
||||
console.log("\nAll MetricData tests passed!");
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Comprehensive test that fetches all endpoints in the tree.
|
||||
*/
|
||||
|
||||
import { BrkClient } from "../index.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('../index.js').AnySeriesPattern} AnyMetricPattern
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if an object is a metric pattern (has indexes() method and by object).
|
||||
* @param {any} obj
|
||||
* @returns {obj is AnyMetricPattern}
|
||||
*/
|
||||
function isMetricPattern(obj) {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === "object" &&
|
||||
typeof obj.indexes === "function" &&
|
||||
obj.by &&
|
||||
typeof obj.by === "object"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all metric patterns from the tree.
|
||||
* @param {Record<string, any>} obj
|
||||
* @param {string} path
|
||||
* @returns {Array<{path: string, metric: AnyMetricPattern}>}
|
||||
*/
|
||||
function getAllMetrics(obj, path = "") {
|
||||
/** @type {Array<{path: string, metric: AnyMetricPattern}>} */
|
||||
const metrics = [];
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const attr = obj[key];
|
||||
if (!attr || typeof attr !== "object") continue;
|
||||
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
// Check if this is a metric pattern using the indexes() method
|
||||
if (isMetricPattern(attr)) {
|
||||
metrics.push({ path: currentPath, metric: attr });
|
||||
}
|
||||
|
||||
// Recurse into nested tree nodes
|
||||
if (typeof attr === "object" && !Array.isArray(attr)) {
|
||||
metrics.push(...getAllMetrics(attr, currentPath));
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function testAllEndpoints() {
|
||||
const client = new BrkClient({
|
||||
baseUrl: "http://localhost:3110",
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const metrics = getAllMetrics(client.series);
|
||||
console.log(`\nFound ${metrics.length} metrics`);
|
||||
|
||||
let success = 0;
|
||||
|
||||
for (const { path, metric } of metrics) {
|
||||
// Use the indexes() method to get all available indexes
|
||||
const indexes = metric.indexes();
|
||||
|
||||
for (const idxName of indexes) {
|
||||
const fullPath = `${path}.by.${idxName}`;
|
||||
|
||||
try {
|
||||
// Verify both access methods work: .by[index] and .get(index)
|
||||
const endpointByProperty = metric.by[idxName];
|
||||
const endpointByGet = metric.get(idxName);
|
||||
|
||||
if (!endpointByProperty) {
|
||||
throw new Error(`metric.by.${idxName} is undefined`);
|
||||
}
|
||||
if (!endpointByGet) {
|
||||
throw new Error(`metric.get('${idxName}') returned undefined`);
|
||||
}
|
||||
|
||||
await endpointByProperty.last(0);
|
||||
success++;
|
||||
console.log(`OK: ${fullPath}`);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`FAIL: ${fullPath} -> ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Results ===`);
|
||||
console.log(`Success: ${success}`);
|
||||
}
|
||||
|
||||
testAllEndpoints();
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"outDir": "/tmp/brk",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["dist", "tests"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
*.js
|
||||
-653
@@ -1,653 +0,0 @@
|
||||
declare module 'lean-qr' {
|
||||
interface ImageDataLike {
|
||||
readonly data: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
interface Context2DLike<DataT extends ImageDataLike> {
|
||||
createImageData(width: number, height: number): DataT;
|
||||
putImageData(data: DataT, x: number, y: number): void;
|
||||
}
|
||||
|
||||
interface CanvasLike<DataT extends ImageDataLike> {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(type: '2d'): Context2DLike<DataT> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A colour in `[red, green, blue, alpha]` format (all values from 0 to 255).
|
||||
* If alpha is omitted, it is assumed to be 255 (opaque).
|
||||
*/
|
||||
export type RGBA = readonly [number, number, number, number?];
|
||||
|
||||
export interface Bitmap1D {
|
||||
/**
|
||||
* Appends a sequence of bits.
|
||||
*
|
||||
* @param value an integer containing the bits to append (big endian).
|
||||
* @param bits the number of bits to read from `value`. Must be between 1 and 24.
|
||||
*/
|
||||
push(value: number, bits: number): void;
|
||||
}
|
||||
|
||||
export interface StringOptions {
|
||||
/** the text to use for modules which are 'on' (typically black) */
|
||||
on?: string;
|
||||
|
||||
/** the text to use for modules which are 'off' (typically white) */
|
||||
off?: string;
|
||||
|
||||
/** the text to use for linefeeds between rows */
|
||||
lf?: string;
|
||||
|
||||
/** the padding to apply around the output (populated with 'off' modules) */
|
||||
pad?: number;
|
||||
|
||||
/**
|
||||
* the padding to apply on the left and right of the output (populated with 'off' modules)
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padX?: number;
|
||||
|
||||
/**
|
||||
* the padding to apply on the top and bottom of the output (populated with 'off' modules)
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padY?: number;
|
||||
}
|
||||
|
||||
export interface ImageDataOptions {
|
||||
/** the colour to use for modules which are 'on' (typically black) */
|
||||
on?: RGBA;
|
||||
|
||||
/** the colour to use for modules which are 'off' (typically white) */
|
||||
off?: RGBA;
|
||||
|
||||
/** the padding to apply around the output (filled with 'off') */
|
||||
pad?: number;
|
||||
|
||||
/**
|
||||
* the padding to apply on the left and right of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padX?: number;
|
||||
|
||||
/**
|
||||
* the padding to apply on the top and bottom of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padY?: number;
|
||||
}
|
||||
|
||||
export interface Bitmap2D {
|
||||
/** the width / height of the QR code in modules (excluding any padding) */
|
||||
readonly size: number;
|
||||
|
||||
/**
|
||||
* Read the state of a module from the QR code.
|
||||
*
|
||||
* @param x the x coordinate to read. Can be negative / out of bounds.
|
||||
* @param y the y coordinate to read. Can be negative / out of bounds.
|
||||
* @returns true if the requested module is set (i.e. typically black)
|
||||
*/
|
||||
get(x: number, y: number): boolean;
|
||||
|
||||
/**
|
||||
* Generate a string containing the QR code, suitable for displaying in a
|
||||
* terminal environment. Generally, you should customise on and off to use
|
||||
* the ANSI escapes of your target terminal for better rendering.
|
||||
*
|
||||
* @param options optional configuration for the display.
|
||||
*/
|
||||
toString(options?: Readonly<StringOptions>): string;
|
||||
|
||||
/**
|
||||
* Generate image data containing the QR code, at a scale of 1 pixel per
|
||||
* module. Use this if you need more control than toCanvas allows.
|
||||
*
|
||||
* @param context a context to use for creating the image data.
|
||||
* @param options optional configuration for the display.
|
||||
*/
|
||||
toImageData<DataT extends ImageDataLike>(
|
||||
context: Context2DLike<DataT>,
|
||||
options?: Readonly<ImageDataOptions>,
|
||||
): DataT;
|
||||
|
||||
/**
|
||||
* Generate a `data:image/*` URL for the QR code.
|
||||
*
|
||||
* @param options optional configuration for the output.
|
||||
* @returns a string suitable for use as the `src` of an `img` tag.
|
||||
*/
|
||||
toDataURL(
|
||||
options?: Readonly<
|
||||
ImageDataOptions & {
|
||||
type?: `image/${string}`;
|
||||
scale?: number;
|
||||
}
|
||||
>,
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Populate a given canvas with the QR code, at a scale of 1 pixel per
|
||||
* module. Set image-rendering: pixelated and scale the canvas using CSS
|
||||
* for a large image. Automatically resizes the canvas to fit the QR code
|
||||
* if necessary.
|
||||
*
|
||||
* @param canvas the canvas to populate.
|
||||
* @param options optional configuration for the display.
|
||||
*/
|
||||
toCanvas(
|
||||
canvas: CanvasLike<ImageDataLike>,
|
||||
options?: Readonly<ImageDataOptions>,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type Mask = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
export type Mode = (data: Bitmap1D, version: number) => void;
|
||||
export interface ModeFactory {
|
||||
(value: string): Mode;
|
||||
/** a function which returns true when given a character which the current mode can represent */
|
||||
test(string: string): boolean;
|
||||
/** a function which returns an estimate of the number of bits required to encode a given value */
|
||||
est(value: string, version: number): number;
|
||||
/** an optional ECI which must be active for this mode to be interpreted correctly by a reader */
|
||||
eci?: number;
|
||||
}
|
||||
|
||||
interface ModeAutoOptions {
|
||||
/** a list of modes which can be considered when encoding a message */
|
||||
modes?: ReadonlyArray<ModeFactory>;
|
||||
}
|
||||
|
||||
export const mode: Readonly<{
|
||||
/** automatically picks the most optimal combination of modes for the requested message */
|
||||
auto(value: string, options?: Readonly<ModeAutoOptions>): Mode;
|
||||
/** concatenates multiple modes together */
|
||||
multi(...modes: ReadonlyArray<Mode>): Mode;
|
||||
/** sets the Extended Channel Interpretation for the message from this point onwards */
|
||||
eci(id: number): Mode;
|
||||
/** supports `0-9` and stores 3 characters per 10 bits */
|
||||
numeric: ModeFactory;
|
||||
/** supports `0-9A-Z $%*+-./:` and stores 2 characters per 11 bits */
|
||||
alphaNumeric: ModeFactory;
|
||||
/** arbitrary byte data, typically combined with `eci` */
|
||||
bytes(data: Uint8Array | ReadonlyArray<number>): Mode;
|
||||
/** supports 7-bit ASCII and stores 1 character per 8 bits with no ECI */
|
||||
ascii: ModeFactory;
|
||||
/** supports 8-bit ISO-8859-1 and stores 1 character per 8 bits with ECI 3 */
|
||||
iso8859_1: ModeFactory;
|
||||
/** supports double-byte Shift-JIS characters stores 1 character per 13 bits */
|
||||
shift_jis: ModeFactory;
|
||||
/** supports variable length UTF-8 with ECI 26 */
|
||||
utf8: ModeFactory;
|
||||
}>;
|
||||
|
||||
export type Correction = number & { readonly _: unique symbol };
|
||||
export const correction: Readonly<{
|
||||
/**
|
||||
* minimum possible correction level (same as L)
|
||||
* @deprecated use correction.L
|
||||
*/
|
||||
min: Correction;
|
||||
/** ~7.5% error tolerance, ~25% data overhead */
|
||||
L: Correction;
|
||||
/** ~15% error tolerance, ~60% data overhead */
|
||||
M: Correction;
|
||||
/** ~22.5% error tolerance, ~120% data overhead */
|
||||
Q: Correction;
|
||||
/** ~30% error tolerance, ~190% data overhead */
|
||||
H: Correction;
|
||||
/**
|
||||
* maximum possible correction level (same as H)
|
||||
* @deprecated use correction.H
|
||||
*/
|
||||
max: Correction;
|
||||
}>;
|
||||
|
||||
export interface GenerateOptions extends ModeAutoOptions {
|
||||
/** the minimum correction level to use (higher levels may still be used if the chosen version has space) */
|
||||
minCorrectionLevel?: Correction;
|
||||
/** the maximum correction level to use */
|
||||
maxCorrectionLevel?: Correction;
|
||||
/** the minimum version (size) of code to generate (must be between 1 and 40) */
|
||||
minVersion?: number;
|
||||
/** the maximum version (size) of code to generate (must be between 1 and 40) */
|
||||
maxVersion?: number;
|
||||
/** a mask to use on the QR code (should be left as `null` for ISO compliance but may be changed for artistic effect) */
|
||||
mask?: null | Mask;
|
||||
/** padding bits to use for extra space in the QR code (should be left as the default for ISO compliance but may be changed for artistic effect) */
|
||||
trailer?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code.
|
||||
*
|
||||
* @param data either a string, or a pre-encoded mode.
|
||||
* @param options optional configuration for the QR code.
|
||||
* @returns the requested QR code.
|
||||
*/
|
||||
export type GenerateFn = (
|
||||
data: Mode | string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
) => Bitmap2D;
|
||||
interface Generate extends GenerateFn {
|
||||
/**
|
||||
* Creates a scoped `generate` function which considers additional modes
|
||||
* when using auto encoding.
|
||||
*
|
||||
* @param modes the modes to add.
|
||||
* @returns a `generate` function which will additionally consider the
|
||||
* given modes when using auto encoding.
|
||||
*
|
||||
* @deprecated this will be removed in version 3. Prefer passing an explicit list of modes when calling `generate`.
|
||||
*/
|
||||
with(...modes: ReadonlyArray<ModeFactory>): GenerateFn;
|
||||
}
|
||||
export const generate: Generate;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/nano' {
|
||||
import type {
|
||||
Correction,
|
||||
Bitmap2D as FullBitmap2D,
|
||||
GenerateOptions as FullGenerateOptions,
|
||||
} from 'lean-qr';
|
||||
import { correction as fullCorrection } from 'lean-qr';
|
||||
|
||||
export type { Correction };
|
||||
|
||||
export const correction: Pick<typeof fullCorrection, 'L' | 'M' | 'Q' | 'H'>;
|
||||
|
||||
export type Bitmap2D = Pick<FullBitmap2D, 'size' | 'get' | 'toCanvas'>;
|
||||
|
||||
export type GenerateOptions = Pick<
|
||||
FullGenerateOptions,
|
||||
'minCorrectionLevel' | 'minVersion'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Generate a QR code.
|
||||
*
|
||||
* @param data either a string, or a pre-encoded mode.
|
||||
* @param options optional configuration for the QR code.
|
||||
* @returns the requested QR code.
|
||||
*/
|
||||
export function generate(
|
||||
data: string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
): Bitmap2D;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/extras/svg' {
|
||||
import type { Bitmap2D as FullBitmap2D } from 'lean-qr';
|
||||
|
||||
type Bitmap2D = Pick<FullBitmap2D, 'size' | 'get'>;
|
||||
|
||||
export interface SVGOptions {
|
||||
/** the colour to use for modules which are 'on' (typically black) */
|
||||
on?: string;
|
||||
/** the colour to use for modules which are 'off' (typically white) */
|
||||
off?: string;
|
||||
/** the padding to apply around the output (filled with 'off') */
|
||||
pad?: number;
|
||||
/**
|
||||
* the padding to apply on the left and right of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padX?: number;
|
||||
/**
|
||||
* the padding to apply on the top and bottom of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padY?: number;
|
||||
/** a width to apply to the resulting image (overrides `scale`) */
|
||||
width?: number | null;
|
||||
/** a height to apply to the resulting image (overrides `scale`) */
|
||||
height?: number | null;
|
||||
/** a scale to apply to the resulting image (`scale` pixels = 1 module) */
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the raw outline of the QR code for use in an existing SVG.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @returns a string suitable for passing to the `d` attribute of a `path`.
|
||||
*/
|
||||
export function toSvgPath(code: Bitmap2D): string;
|
||||
|
||||
/**
|
||||
* Generate an SVG element which can be added to the DOM.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @param options optional configuration for the display.
|
||||
* @returns an SVG element.
|
||||
*/
|
||||
export function toSvg(
|
||||
code: Bitmap2D,
|
||||
target: Document | SVGElement,
|
||||
options?: Readonly<SVGOptions>,
|
||||
): SVGElement;
|
||||
|
||||
/**
|
||||
* Generate an SVG document which can be exported to a file or served from a
|
||||
* web server.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @param options optional configuration for the display.
|
||||
* @returns an SVG document.
|
||||
*/
|
||||
export function toSvgSource(
|
||||
code: Bitmap2D,
|
||||
options?: Readonly<
|
||||
SVGOptions & {
|
||||
/** `true` to include an XML declaration at the start of the source (for standalone documents which will not be embedded inside another document) */
|
||||
xmlDeclaration?: boolean;
|
||||
}
|
||||
>,
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Generate a `data:image/svg+xml` URL.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @param options optional configuration for the display.
|
||||
* @returns a string suitable for use as the `src` of an `img` tag.
|
||||
*/
|
||||
export function toSvgDataURL(
|
||||
code: Bitmap2D,
|
||||
options?: Readonly<SVGOptions>,
|
||||
): string;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/extras/node_export' {
|
||||
import type { RGBA, Bitmap2D as FullBitmap2D } from 'lean-qr';
|
||||
|
||||
type Bitmap2D = Pick<FullBitmap2D, 'size' | 'get'>;
|
||||
|
||||
export interface PNGOptions {
|
||||
/** the colour to use for modules which are 'on' (typically black) */
|
||||
on?: RGBA;
|
||||
/** the colour to use for modules which are 'off' (typically white) */
|
||||
off?: RGBA;
|
||||
/** the padding to apply around the output (filled with 'off') */
|
||||
pad?: number;
|
||||
/**
|
||||
* the padding to apply on the left and right of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padX?: number;
|
||||
/**
|
||||
* the padding to apply on the top and bottom of the output (filled with 'off')
|
||||
* @deprecated use `pad` instead
|
||||
*/
|
||||
padY?: number;
|
||||
/** a scale to apply to the resulting image (`scale` pixels = 1 module) */
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PNG document which can be exported to a file or served from a
|
||||
* web server.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @param options optional configuration for the display.
|
||||
* @returns a PNG document.
|
||||
*/
|
||||
export function toPngBuffer(
|
||||
code: Bitmap2D,
|
||||
options?: Readonly<PNGOptions>,
|
||||
): Uint8Array;
|
||||
|
||||
/**
|
||||
* Generate a `data:image/png` URL.
|
||||
*
|
||||
* @param code the QR code to convert.
|
||||
* @param options optional configuration for the display.
|
||||
* @returns a string suitable for use as the `src` of an `img` tag.
|
||||
*/
|
||||
export function toPngDataURL(
|
||||
code: Bitmap2D,
|
||||
options?: Readonly<PNGOptions>,
|
||||
): string;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/extras/react' {
|
||||
import type {
|
||||
Bitmap2D as FullBitmap2D,
|
||||
GenerateOptions,
|
||||
ImageDataOptions,
|
||||
} from 'lean-qr';
|
||||
import type {
|
||||
SVGOptions,
|
||||
toSvgDataURL as toSvgDataURLFn,
|
||||
} from 'lean-qr/extras/svg';
|
||||
|
||||
export interface AsyncFramework<T> {
|
||||
createElement: (
|
||||
type: 'canvas',
|
||||
props: {
|
||||
ref: any;
|
||||
style: { imageRendering: 'pixelated' };
|
||||
className: string;
|
||||
},
|
||||
) => T;
|
||||
useRef<T>(initialValue: T | null): { readonly current: T | null };
|
||||
useEffect(fn: () => void | (() => void), deps: unknown[]): void;
|
||||
}
|
||||
|
||||
interface QRComponentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface AsyncQRComponentProps
|
||||
extends ImageDataOptions,
|
||||
GenerateOptions,
|
||||
QRComponentProps {}
|
||||
|
||||
export type AsyncQRComponent<T> = (
|
||||
props: Readonly<AsyncQRComponentProps>,
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* Generate an asynchronous QR component (rendering to a `canvas`).
|
||||
* You should call this just once, in the global scope.
|
||||
*
|
||||
* ```js
|
||||
* import * as React from 'react';
|
||||
* import { generate } from 'lean-qr';
|
||||
* import { makeAsyncComponent } from 'lean-qr/extras/react';
|
||||
* const QR = makeAsyncComponent(React, generate);
|
||||
* ```
|
||||
*
|
||||
* This is not suitable for server-side rendering (use `makeSyncComponent`
|
||||
* instead).
|
||||
*
|
||||
* @param framework the framework to use (e.g. `React`).
|
||||
* @param generate the `generate` function to use
|
||||
* (from `lean-qr` or `lean-qr/nano`).
|
||||
* @param defaultProps optional default properties to apply when the
|
||||
* component is used (overridden by properties set on use).
|
||||
* @returns a component which can be rendered elsewhere.
|
||||
*/
|
||||
export function makeAsyncComponent<T>(
|
||||
framework: Readonly<AsyncFramework<T>>,
|
||||
generate: (
|
||||
data: string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
) => Pick<FullBitmap2D, 'toCanvas'>,
|
||||
defaultProps?: Readonly<Partial<AsyncQRComponentProps>>,
|
||||
): AsyncQRComponent<T>;
|
||||
|
||||
export interface SyncFramework<T> {
|
||||
createElement: (
|
||||
type: 'img',
|
||||
props: {
|
||||
src: string;
|
||||
style: { imageRendering: 'pixelated' };
|
||||
className: string;
|
||||
},
|
||||
) => T;
|
||||
useMemo<T>(fn: () => T, deps: unknown[]): T;
|
||||
}
|
||||
|
||||
export interface SyncQRComponentProps
|
||||
extends SVGOptions,
|
||||
GenerateOptions,
|
||||
QRComponentProps {}
|
||||
|
||||
export type SyncQRComponent<T> = (props: Readonly<SyncQRComponentProps>) => T;
|
||||
|
||||
/**
|
||||
* Generate a synchronous QR component (rendering to an SVG).
|
||||
* You should call this just once, in the global scope.
|
||||
*
|
||||
* ```js
|
||||
* import * as React from 'react';
|
||||
* import { generate } from 'lean-qr';
|
||||
* import { toSvgDataURL } from 'lean-qr/extras/svg';
|
||||
* import { makeSyncComponent } from 'lean-qr/extras/react';
|
||||
* const QR = makeSyncComponent(React, generate, toSvgDataURL);
|
||||
* ```
|
||||
*
|
||||
* This is best suited for server-side rendering (prefer
|
||||
* `makeAsyncComponent` if you only need client-side rendering).
|
||||
*
|
||||
* @param framework the framework to use (e.g. `React`).
|
||||
* @param generate the `generate` function to use
|
||||
* (from `lean-qr` or `lean-qr/nano`).
|
||||
* @param toSvgDataURL the `toSvgDataURL` function to use
|
||||
* (from `lean-qr/extras/svg`).
|
||||
* @param defaultProps optional default properties to apply when the
|
||||
* component is used (overridden by properties set on use).
|
||||
* @returns a component which can be rendered elsewhere.
|
||||
*/
|
||||
export function makeSyncComponent<T>(
|
||||
framework: Readonly<SyncFramework<T>>,
|
||||
generate: (
|
||||
data: string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
) => Pick<FullBitmap2D, 'size' | 'get'>,
|
||||
toSvgDataURL: typeof toSvgDataURLFn,
|
||||
defaultProps?: Readonly<Partial<SyncQRComponentProps>>,
|
||||
): SyncQRComponent<T>;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/extras/vue' {
|
||||
import type {
|
||||
Bitmap2D as FullBitmap2D,
|
||||
GenerateOptions,
|
||||
ImageDataOptions,
|
||||
} from 'lean-qr';
|
||||
import type {
|
||||
SVGOptions,
|
||||
toSvgDataURL as toSvgDataURLFn,
|
||||
} from 'lean-qr/extras/svg';
|
||||
|
||||
export interface Framework {
|
||||
h:
|
||||
| ((type: 'canvas', props: { ref: string; style: string }) => unknown)
|
||||
| ((type: 'img', props: { src: string; style: string }) => unknown);
|
||||
}
|
||||
|
||||
interface QRComponentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface VueCanvasComponentProps
|
||||
extends ImageDataOptions,
|
||||
GenerateOptions,
|
||||
QRComponentProps {}
|
||||
|
||||
type VueComponentDefinition<Props> = {
|
||||
props: {
|
||||
[k in keyof Props]-?: {
|
||||
type: { (): Required<Props>[k] };
|
||||
required: undefined extends Props[k] ? false : true;
|
||||
};
|
||||
};
|
||||
} & ThisType<unknown>;
|
||||
|
||||
/**
|
||||
* Generate a QR component which renders to a `canvas`.
|
||||
* You should call this just once, in the global scope.
|
||||
*
|
||||
* ```js
|
||||
* import { h, defineComponent } from 'vue';
|
||||
* import { generate } from 'lean-qr';
|
||||
* import { makeVueCanvasComponent } from 'lean-qr/extras/vue';
|
||||
* export const QR = defineComponent(makeVueCanvasComponent({ h }, generate));
|
||||
* ```
|
||||
*
|
||||
* This is not suitable for server-side rendering (use `makeSyncComponent`
|
||||
* instead).
|
||||
*
|
||||
* @param framework the framework to use (e.g. `{ h }`).
|
||||
* @param generate the `generate` function to use
|
||||
* (from `lean-qr` or `lean-qr/nano`).
|
||||
* @param defaultProps optional default properties to apply when the
|
||||
* component is used (overridden by properties set on use).
|
||||
* @returns a component which can be rendered elsewhere.
|
||||
*/
|
||||
export function makeVueCanvasComponent(
|
||||
framework: Readonly<Framework>,
|
||||
generate: (
|
||||
data: string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
) => Pick<FullBitmap2D, 'toCanvas'>,
|
||||
defaultProps?: Readonly<Partial<VueCanvasComponentProps>>,
|
||||
): VueComponentDefinition<VueCanvasComponentProps>;
|
||||
|
||||
export interface VueSVGComponentProps
|
||||
extends SVGOptions,
|
||||
GenerateOptions,
|
||||
QRComponentProps {}
|
||||
|
||||
/**
|
||||
* Generate a QR component which renders to an SVG.
|
||||
* You should call this just once, in the global scope:
|
||||
*
|
||||
* ```js
|
||||
* import { h, defineComponent } from 'vue';
|
||||
* import { generate } from 'lean-qr';
|
||||
* import { toSvgDataURL } from 'lean-qr/extras/svg';
|
||||
* import { makeVueSvgComponent } from 'lean-qr/extras/vue';
|
||||
* export const QR = defineComponent(makeVueSvgComponent({ h }, generate, toSvgDataURL));
|
||||
* ```
|
||||
*
|
||||
* This is best suited for server-side rendering (prefer
|
||||
* `makeAsyncComponent` if you only need client-side rendering).
|
||||
*
|
||||
* @param framework the framework to use (e.g. `{ h }`).
|
||||
* @param generate the `generate` function to use
|
||||
* (from `lean-qr` or `lean-qr/nano`).
|
||||
* @param toSvgDataURL the `toSvgDataURL` function to use
|
||||
* (from `lean-qr/extras/svg`).
|
||||
* @param defaultProps optional default properties to apply when the
|
||||
* component is used (overridden by properties set on use).
|
||||
* @returns a component which can be rendered elsewhere.
|
||||
*/
|
||||
export function makeVueSvgComponent(
|
||||
framework: Readonly<Framework>,
|
||||
generate: (
|
||||
data: string,
|
||||
options?: Readonly<GenerateOptions>,
|
||||
) => Pick<FullBitmap2D, 'size' | 'get'>,
|
||||
toSvgDataURL: typeof toSvgDataURLFn,
|
||||
defaultProps?: Readonly<Partial<VueSVGComponentProps>>,
|
||||
): VueComponentDefinition<VueSVGComponentProps>;
|
||||
}
|
||||
|
||||
declare module 'lean-qr/extras/errors' {
|
||||
/**
|
||||
* Convert an error into a human-readable message. This is intended for use
|
||||
* with Lean QR errors, but will return somewhat meaningful messages for
|
||||
* other errors too.
|
||||
*
|
||||
* @param error the error to convert.
|
||||
* @returns a human-readable message explaining the error.
|
||||
*/
|
||||
export function readError(error: unknown): string;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
*charts.pro*
|
||||
light*.js
|
||||
Vendored
-8
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,433 +0,0 @@
|
||||
const DEFAULT_SEPARATORS = "_- :/";
|
||||
const DEFAULT_TRIGRAM_BUDGET = 6;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const DEFAULT_MIN_SCORE = 2;
|
||||
|
||||
/**
|
||||
* Search configuration.
|
||||
*
|
||||
* Defaults work well for most use cases.
|
||||
* Tweak `trigramBudget` to trade speed for typo tolerance.
|
||||
*/
|
||||
export class QuickMatchConfig {
|
||||
/** Characters that separate words in items (e.g. "hash_rate" → ["hash", "rate"]).
|
||||
* @type {string} */
|
||||
separators = DEFAULT_SEPARATORS;
|
||||
|
||||
/** Max results returned per query.
|
||||
* @type {number} */
|
||||
limit = DEFAULT_LIMIT;
|
||||
|
||||
/** How hard to try matching typos (0 = off, 3–6 = fast, 9–15 = thorough, max 20).
|
||||
* @type {number} */
|
||||
trigramBudget = DEFAULT_TRIGRAM_BUDGET;
|
||||
|
||||
/** Min overlap required for a typo match. Higher = fewer false positives.
|
||||
* @type {number} */
|
||||
minScore = DEFAULT_MIN_SCORE;
|
||||
|
||||
/** @param {number} n - Max results (default: 100, min: 1) */
|
||||
withLimit(n) {
|
||||
this.limit = Math.max(1, n);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param {number} n - Trigram budget (0-20, default: 6) */
|
||||
withTrigramBudget(n) {
|
||||
this.trigramBudget = Math.max(0, Math.min(20, n));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param {string} s - Separator characters (default: '_- :/') */
|
||||
withSeparators(s) {
|
||||
this.separators = s;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param {number} n - Min trigram score (default: 2, min: 1) */
|
||||
withMinScore(n) {
|
||||
this.minScore = Math.max(1, n);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant search over a list of strings.
|
||||
*
|
||||
* Supports exact words, prefixes ("dom" → "dominance"), joined words
|
||||
* ("hashrate" → "hash_rate"), and typo tolerance ("suply" → "supply").
|
||||
* Results are ranked: exact matches first, then by specificity.
|
||||
*/
|
||||
export class QuickMatch {
|
||||
/** @param {string[]} items - Searchable items (lowercase) @param {QuickMatchConfig} [config] */
|
||||
constructor(items, config = new QuickMatchConfig()) {
|
||||
this.config = config;
|
||||
this.items = items;
|
||||
/** @type {Map<string, number[]>} */
|
||||
this.wordIndex = new Map();
|
||||
/** @type {Map<string, number[]>} */
|
||||
this.trigramIndex = new Map();
|
||||
this._sepLookup = sepLookup(config.separators);
|
||||
this._scores = new Uint32Array(items.length);
|
||||
/** @type {number[]} */
|
||||
this._dirty = [];
|
||||
|
||||
let maxWordLen = 0;
|
||||
let maxQueryLen = 0;
|
||||
let maxWords = 0;
|
||||
const sep = this._sepLookup;
|
||||
|
||||
for (let idx = 0; idx < items.length; idx++) {
|
||||
const item = items[idx];
|
||||
if (item.length > maxQueryLen) maxQueryLen = item.length;
|
||||
|
||||
const words = [];
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i <= item.length; i++) {
|
||||
if (i < item.length && !sep[item.charCodeAt(i)]) continue;
|
||||
if (i > start) {
|
||||
const word = item.slice(start, i);
|
||||
words.push(word);
|
||||
if (word.length > maxWordLen) maxWordLen = word.length;
|
||||
for (let len = 1; len <= word.length; len++) {
|
||||
addToIndex(this.wordIndex, word.slice(0, len), idx);
|
||||
}
|
||||
for (let k = 0; k <= word.length - 3; k++) {
|
||||
addToIndex(this.trigramIndex, word[k] + word[k + 1] + word[k + 2], idx);
|
||||
}
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
const compound = words[i] + words[i + 1];
|
||||
// A joined-word query ("hashrate") can be longer than any single
|
||||
// word. Capping at the longest index key keeps the DDoS guard
|
||||
// data-bounded while still letting it match.
|
||||
if (compound.length > maxWordLen) maxWordLen = compound.length;
|
||||
const from = words[i].length + 1;
|
||||
for (let len = from; len <= compound.length; len++) {
|
||||
addToIndex(this.wordIndex, compound.slice(0, len), idx);
|
||||
}
|
||||
}
|
||||
|
||||
if (words.length > maxWords) maxWords = words.length;
|
||||
}
|
||||
|
||||
this.maxWordLen = maxWordLen + 4;
|
||||
this.maxQueryLen = maxQueryLen + 6;
|
||||
this.maxWords = maxWords + 2;
|
||||
}
|
||||
|
||||
/** @param {string} query */
|
||||
matches(query) {
|
||||
return this.matchesWith(query, this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {QuickMatchConfig} config
|
||||
*/
|
||||
matchesWith(query, config) {
|
||||
const { limit, trigramBudget } = config;
|
||||
const sep =
|
||||
config.separators === this.config.separators
|
||||
? this._sepLookup
|
||||
: sepLookup(config.separators);
|
||||
|
||||
const q = normalize(query);
|
||||
if (!q || q.length > this.maxQueryLen) return [];
|
||||
|
||||
const qwords = splitWords(q, sep, this.maxWordLen);
|
||||
if (!qwords.length || qwords.length > this.maxWords) return [];
|
||||
|
||||
const known = [];
|
||||
const unknown = [];
|
||||
|
||||
for (const w of qwords) {
|
||||
const hits = this.wordIndex.get(w);
|
||||
if (hits) {
|
||||
known.push(hits);
|
||||
} else if (w.length >= 3 && unknown.length < trigramBudget) {
|
||||
unknown.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
const pool = intersect(known);
|
||||
|
||||
// Try typo matching for unknown words
|
||||
if (unknown.length && trigramBudget) {
|
||||
const { _scores: scores, _dirty: dirty } = this;
|
||||
|
||||
if (pool) {
|
||||
for (const i of pool) {
|
||||
scores[i] = 1;
|
||||
dirty.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const hitCount = this._scoreTrigrams(
|
||||
unknown,
|
||||
trigramBudget,
|
||||
pool !== null,
|
||||
Math.max(0, q.length - 3),
|
||||
);
|
||||
const minScore = Math.max(config.minScore, Math.ceil(hitCount / 2));
|
||||
const result = this._rank(dirty, minScore, qwords, sep, limit);
|
||||
|
||||
for (const i of dirty) scores[i] = 0;
|
||||
dirty.length = 0;
|
||||
|
||||
if (result.length > 0) return result;
|
||||
}
|
||||
|
||||
// Rank known candidates (intersection, or union as fallback)
|
||||
const candidates = pool || union(known);
|
||||
return candidates.length > 0
|
||||
? this._rank(candidates, null, qwords, sep, limit)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @private @param {string[]} unknown @param {number} budget @param {boolean} poolOnly @param {number} minLen */
|
||||
_scoreTrigrams(unknown, budget, poolOnly, minLen) {
|
||||
const { _scores: scores, _dirty: dirty, items } = this;
|
||||
const visited = new Set();
|
||||
const maxRounds = budget;
|
||||
let hits = 0;
|
||||
|
||||
outer: for (let round = 0; round < maxRounds; round++) {
|
||||
for (const word of unknown) {
|
||||
if (budget <= 0) break outer;
|
||||
|
||||
const pos = trigramPosition(word.length, round);
|
||||
if (pos < 0) continue;
|
||||
|
||||
const tri = word[pos] + word[pos + 1] + word[pos + 2];
|
||||
if (visited.has(tri)) continue;
|
||||
visited.add(tri);
|
||||
budget--;
|
||||
|
||||
const matched = this.trigramIndex.get(tri);
|
||||
if (!matched) continue;
|
||||
hits++;
|
||||
|
||||
if (poolOnly) {
|
||||
for (let j = 0; j < matched.length; j++) {
|
||||
const i = matched[j];
|
||||
if (scores[i] > 0) scores[i]++;
|
||||
}
|
||||
} else {
|
||||
for (let j = 0; j < matched.length; j++) {
|
||||
const i = matched[j];
|
||||
if (items[i].length >= minLen) {
|
||||
if (scores[i] === 0) dirty.push(i);
|
||||
scores[i]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number[]} indices
|
||||
* @param {number|null} minScore
|
||||
* @param {string[]} qwords
|
||||
* @param {Uint8Array} sep
|
||||
* @param {number} limit
|
||||
*/
|
||||
_rank(indices, minScore, qwords, sep, limit) {
|
||||
const { items, _scores: scores } = this;
|
||||
/** @type {[number, number][][]} */
|
||||
const buckets = Array.from({ length: qwords.length + 1 }, () => []);
|
||||
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const idx = indices[i];
|
||||
if (minScore !== null && scores[idx] < minScore) continue;
|
||||
const [matched, position] = wordMatch(items[idx], qwords, sep);
|
||||
buckets[matched].push([idx, position]);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let ps = buckets.length - 1; ps >= 0 && results.length < limit; ps--) {
|
||||
const bucket = buckets[ps];
|
||||
if (!bucket.length) continue;
|
||||
bucket.sort(
|
||||
([a, pa], [b, pb]) =>
|
||||
scores[b] - scores[a] ||
|
||||
pa - pb ||
|
||||
items[a].length - items[b].length ||
|
||||
(items[a] < items[b] ? -1 : 1), // item text, asc (total order)
|
||||
);
|
||||
const take = Math.min(bucket.length, limit - results.length);
|
||||
for (let i = 0; i < take; i++) results.push(items[bucket[i][0]]);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** @param {string} query */
|
||||
function normalize(query) {
|
||||
let out = "";
|
||||
let start = 0;
|
||||
let end = query.length;
|
||||
while (start < end && query.charCodeAt(start) <= 32) start++;
|
||||
while (end > start && query.charCodeAt(end - 1) <= 32) end--;
|
||||
for (let i = start; i < end; i++) {
|
||||
const c = query.charCodeAt(i);
|
||||
if (c >= 128) continue;
|
||||
out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : query[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {string} separators */
|
||||
function sepLookup(separators) {
|
||||
const t = new Uint8Array(128);
|
||||
for (let i = 0; i < separators.length; i++) {
|
||||
const c = separators.charCodeAt(i);
|
||||
if (c < 128) t[c] = 1;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {Uint8Array} sep
|
||||
* @param {number} maxLen
|
||||
*/
|
||||
function splitWords(text, sep, maxLen) {
|
||||
/** @type {string[]} */
|
||||
const words = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
if (i < text.length && !sep[text.charCodeAt(i)]) continue;
|
||||
if (i > start) {
|
||||
const w = text.slice(start, i);
|
||||
if (w.length <= maxLen && !words.includes(w)) words.push(w);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string, number[]>} index
|
||||
* @param {string} key
|
||||
* @param {number} value
|
||||
*/
|
||||
function addToIndex(index, key, value) {
|
||||
const arr = index.get(key);
|
||||
if (arr) {
|
||||
if (arr[arr.length - 1] !== value) arr.push(value);
|
||||
} else {
|
||||
index.set(key, [value]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number[][]} arrays */
|
||||
function union(arrays) {
|
||||
if (arrays.length <= 1) return arrays[0] || [];
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const arr of arrays) {
|
||||
for (const idx of arr) {
|
||||
if (!seen.has(idx)) {
|
||||
seen.add(idx);
|
||||
result.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @param {number[][]} arrays @returns {number[]|null} */
|
||||
function intersect(arrays) {
|
||||
if (arrays.length <= 1) return arrays[0] || null;
|
||||
|
||||
let si = 0;
|
||||
for (let i = 1; i < arrays.length; i++) {
|
||||
if (arrays[i].length < arrays[si].length) si = i;
|
||||
}
|
||||
|
||||
const result = arrays[si].slice();
|
||||
for (let i = 0; i < arrays.length; i++) {
|
||||
if (i === si) continue;
|
||||
let w = 0;
|
||||
for (let j = 0; j < result.length; j++) {
|
||||
if (bsearch(arrays[i], result[j])) result[w++] = result[j];
|
||||
}
|
||||
result.length = w;
|
||||
if (!w) return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} arr
|
||||
* @param {number} val
|
||||
*/
|
||||
function bsearch(arr, val) {
|
||||
let lo = 0,
|
||||
hi = arr.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] === val) return true;
|
||||
if (arr[mid] < val) lo = mid + 1;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns query words against the item's words, in order.
|
||||
* @param {string} item @param {string[]} qwords @param {Uint8Array} sep
|
||||
* @returns {[number, number]} `[matched, position]` - query words matched as
|
||||
* an in-order subsequence, and the item-word index where that run starts
|
||||
* (or the item's word count when nothing matched).
|
||||
*/
|
||||
function wordMatch(item, qwords, sep) {
|
||||
const len = item.length;
|
||||
let matched = 0;
|
||||
let position = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < len) {
|
||||
while (pos < len && sep[item.charCodeAt(pos)]) pos++;
|
||||
if (pos >= len) break;
|
||||
|
||||
const ws = pos;
|
||||
while (pos < len && !sep[item.charCodeAt(pos)]) pos++;
|
||||
|
||||
const qw = qwords[matched];
|
||||
if (qw !== undefined && pos - ws >= qw.length && item.startsWith(qw, ws)) {
|
||||
matched++;
|
||||
} else if (matched === 0) {
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
return [matched, position];
|
||||
}
|
||||
|
||||
/** @param {number} len @param {number} round */
|
||||
function trigramPosition(len, round) {
|
||||
const max = len - 3;
|
||||
if (max < 0) return -1;
|
||||
if (round === 0) return 0;
|
||||
if (round === 1 && max > 0) return max;
|
||||
if (round === 2 && max > 1) return max >> 1;
|
||||
if (max <= 2) return -1;
|
||||
|
||||
const mid = max >> 1;
|
||||
const off = (round - 2) >> 1;
|
||||
const pos = round & 1 ? Math.max(0, mid - off) : mid + off;
|
||||
return pos === 0 || pos >= max || pos === mid ? -1 : pos;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"outDir": "/tmp/brk",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"exclude": ["assets", "./scripts/modules"],
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_debug() {
|
||||
if [[ "${DEBUG:-}" == "1" ]]; then
|
||||
echo -e "${YELLOW}[DEBUG]${NC} $1"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if required tools are installed
|
||||
check_dependencies() {
|
||||
local missing_deps=()
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
missing_deps+=("curl")
|
||||
fi
|
||||
|
||||
if ! command -v grep &> /dev/null; then
|
||||
missing_deps+=("grep")
|
||||
fi
|
||||
|
||||
if ! command -v sed &> /dev/null; then
|
||||
missing_deps+=("sed")
|
||||
fi
|
||||
|
||||
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||
print_error "Missing required dependencies: ${missing_deps[*]}"
|
||||
print_error "Please install them and try again."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to URL encode a string
|
||||
url_encode() {
|
||||
local string="${1}"
|
||||
local strlen=${#string}
|
||||
local encoded=""
|
||||
local pos c o
|
||||
|
||||
for ((pos=0; pos<strlen; pos++)); do
|
||||
c=${string:$pos:1}
|
||||
case "$c" in
|
||||
[-_.~a-zA-Z0-9] ) o="${c}" ;;
|
||||
* ) printf -v o '%%%02x' "'$c"
|
||||
esac
|
||||
encoded+="${o}"
|
||||
done
|
||||
echo "${encoded}"
|
||||
}
|
||||
|
||||
# Function to create directory if it doesn't exist
|
||||
create_dir() {
|
||||
local dir="$1"
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p "$dir"
|
||||
print_status "Created directory: $dir"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to resolve "latest" version to actual version number
|
||||
resolve_latest_version() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
|
||||
# If version is not "latest", return as-is
|
||||
if [[ "$version" != "latest" ]]; then
|
||||
echo "$version"
|
||||
return
|
||||
fi
|
||||
|
||||
print_status "Resolving 'latest' version for $package_name..." >&2
|
||||
|
||||
# URL encode the package name
|
||||
local encoded_package=$(url_encode "$package_name")
|
||||
local latest_url="https://app.unpkg.com/${encoded_package}@latest"
|
||||
|
||||
# Use curl to follow redirects and get the final URL
|
||||
local final_url
|
||||
final_url=$(curl -L -s -o /dev/null -w '%{url_effective}' "$latest_url")
|
||||
|
||||
if [[ -z "$final_url" ]]; then
|
||||
print_error "Failed to resolve latest version for $package_name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract version from the final URL
|
||||
# Format: https://app.unpkg.com/@solidjs/signals@0.4.1
|
||||
local resolved_version
|
||||
# Use a different delimiter (#) to avoid issues with / in package names
|
||||
resolved_version=$(echo "$final_url" | sed -n "s#.*${package_name}@\([^\/]*\).*#\1#p")
|
||||
|
||||
if [[ -z "$resolved_version" ]]; then
|
||||
print_error "Could not extract version from URL: $final_url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_success "Resolved 'latest' to version: $resolved_version" >&2
|
||||
echo "$resolved_version"
|
||||
}
|
||||
|
||||
# Function to download a file
|
||||
download_file() {
|
||||
local file_url="$1"
|
||||
local local_path="$2"
|
||||
local dir=$(dirname "$local_path")
|
||||
|
||||
create_dir "$dir"
|
||||
|
||||
print_status "Downloading: $file_url"
|
||||
if curl -L -s -f "$file_url" -o "$local_path"; then
|
||||
print_success "Downloaded: $local_path"
|
||||
return 0
|
||||
else
|
||||
print_error "Failed to download: $file_url"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to parse HTML and extract file/folder links using a much simpler approach
|
||||
parse_directory() {
|
||||
local html_content="$1"
|
||||
local current_path="$2"
|
||||
local package_name="$3"
|
||||
local version="$4"
|
||||
|
||||
print_debug "Parsing directory for path: '$current_path'"
|
||||
|
||||
# The HTML contains the original (unencoded) package name in URLs
|
||||
# So we search for the original package name, not the encoded version
|
||||
|
||||
# Find all links that point to files/folders in this package
|
||||
# Look for the pattern: <a href="...">filename</a> or <a href="...">foldername/</a>
|
||||
local links=$(echo "$html_content" | grep -o '<a href="[^"]*"[^>]*>[^<]*</a>' | grep "${package_name}@${version}/files")
|
||||
|
||||
print_debug "Found $(echo "$links" | wc -l) total links"
|
||||
|
||||
if [[ "${DEBUG:-}" == "1" ]]; then
|
||||
print_debug "All found links:"
|
||||
echo "$links" | while read -r link; do
|
||||
[[ -n "$link" ]] && print_debug " $link"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$links" | while read -r link_line; do
|
||||
if [[ -z "$link_line" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract the href URL
|
||||
local href=$(echo "$link_line" | sed -n 's/.*href="\([^"]*\)".*/\1/p')
|
||||
# Extract the link text (what's between <a> and </a>)
|
||||
local link_text=$(echo "$link_line" | sed -n 's/.*<a[^>]*>\([^<]*\)<\/a>.*/\1/p')
|
||||
|
||||
print_debug "Processing link: href='$href' text='$link_text'"
|
||||
|
||||
# Skip if we couldn't extract both parts
|
||||
if [[ -z "$href" ]] || [[ -z "$link_text" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip parent directory links
|
||||
if [[ "$link_text" == "../" ]] || [[ "$link_text" == ".." ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract the file/folder path from the URL
|
||||
# URL format: https://app.unpkg.com/@solidjs/signals@0.4.1/files/path/to/item
|
||||
# Note: href contains the original (unencoded) package name
|
||||
local url_path="${href#*${package_name}@${version}/files}"
|
||||
url_path="${url_path#/}" # Remove leading slash
|
||||
|
||||
print_debug "URL path extracted: '$url_path'"
|
||||
|
||||
# Skip if this is not a direct child of current directory
|
||||
if [[ -n "$current_path" ]]; then
|
||||
# We're in a subdirectory, so the URL path should start with current_path
|
||||
local current_clean="${current_path#/}"
|
||||
if [[ "$url_path" != "${current_clean}"* ]]; then
|
||||
print_debug "Skipping - not in current path"
|
||||
continue
|
||||
fi
|
||||
# Get the relative path from current directory
|
||||
local relative_path="${url_path#${current_clean}}"
|
||||
relative_path="${relative_path#/}"
|
||||
else
|
||||
# We're in root, so relative_path is the same as url_path
|
||||
local relative_path="$url_path"
|
||||
fi
|
||||
|
||||
print_debug "Relative path: '$relative_path'"
|
||||
|
||||
# Skip if this contains subdirectories (we only want direct children)
|
||||
if [[ "$relative_path" == *"/"* ]]; then
|
||||
print_debug "Skipping - contains subdirectories"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip empty paths
|
||||
if [[ -z "$relative_path" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Determine if it's a folder or file based on the link text
|
||||
if [[ "$link_text" == *"/" ]]; then
|
||||
# It's a folder
|
||||
local folder_name="${relative_path%/}"
|
||||
if [[ -n "$current_path" ]]; then
|
||||
echo "FOLDER:${current_path}/${folder_name}"
|
||||
else
|
||||
echo "FOLDER:/${folder_name}"
|
||||
fi
|
||||
else
|
||||
# It's a file
|
||||
if [[ -n "$current_path" ]]; then
|
||||
echo "FILE:${current_path}/${relative_path}"
|
||||
else
|
||||
echo "FILE:/${relative_path}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Function to check if item was already processed
|
||||
is_processed() {
|
||||
local item="$1"
|
||||
local processed_list="$2"
|
||||
|
||||
[[ "$processed_list" == *"|${item}|"* ]]
|
||||
}
|
||||
|
||||
# Function to add item to processed list
|
||||
add_processed() {
|
||||
local item="$1"
|
||||
local processed_list="$2"
|
||||
|
||||
echo "${processed_list}|${item}|"
|
||||
}
|
||||
|
||||
# Function to process a directory (download files and recurse into subdirectories)
|
||||
process_directory() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local dir_path="$3"
|
||||
local output_dir="$4"
|
||||
local processed_dirs="$5"
|
||||
|
||||
# Encode the package name for URL
|
||||
local encoded_package=$(url_encode "$package_name")
|
||||
local app_url="https://app.unpkg.com/${encoded_package}@${version}/files${dir_path}"
|
||||
local download_base_url="https://unpkg.com/${encoded_package}@${version}"
|
||||
|
||||
# Check if we've already processed this directory
|
||||
if is_processed "$dir_path" "$processed_dirs"; then
|
||||
print_warning "Already processed directory: $dir_path"
|
||||
return
|
||||
fi
|
||||
|
||||
print_status "Processing directory: ${dir_path:-'(root)'}"
|
||||
print_status "Fetching: $app_url"
|
||||
|
||||
# Download the directory listing HTML
|
||||
local html_content
|
||||
if ! html_content=$(curl -L -s -f "$app_url"); then
|
||||
print_error "Failed to fetch directory listing: $app_url"
|
||||
return
|
||||
fi
|
||||
|
||||
print_status "Fetched HTML content (${#html_content} characters)"
|
||||
|
||||
# Mark this directory as processed
|
||||
processed_dirs=$(add_processed "$dir_path" "$processed_dirs")
|
||||
|
||||
# Parse the HTML to find files and folders
|
||||
local items
|
||||
items=$(parse_directory "$html_content" "$dir_path" "$package_name" "$version")
|
||||
|
||||
print_debug "Parsed items:"
|
||||
print_debug "$items"
|
||||
|
||||
# Collect unique files and folders
|
||||
local files=()
|
||||
local folders=()
|
||||
local seen_files=""
|
||||
local seen_folders=""
|
||||
|
||||
while IFS= read -r item; do
|
||||
[[ -z "$item" ]] && continue
|
||||
|
||||
if [[ "$item" == FILE:* ]]; then
|
||||
local file_path="${item#FILE:}"
|
||||
if ! is_processed "$file_path" "$seen_files"; then
|
||||
files+=("$file_path")
|
||||
seen_files=$(add_processed "$file_path" "$seen_files")
|
||||
print_debug "Added file: $file_path"
|
||||
fi
|
||||
elif [[ "$item" == FOLDER:* ]]; then
|
||||
local folder_path="${item#FOLDER:}"
|
||||
if ! is_processed "$folder_path" "$seen_folders"; then
|
||||
folders+=("$folder_path")
|
||||
seen_folders=$(add_processed "$folder_path" "$seen_folders")
|
||||
print_debug "Added folder: $folder_path"
|
||||
fi
|
||||
fi
|
||||
done <<< "$items"
|
||||
|
||||
print_status "Found ${#files[@]} files and ${#folders[@]} folders"
|
||||
|
||||
# Download all files in this directory
|
||||
for file_path in "${files[@]}"; do
|
||||
if [[ -n "$file_path" ]]; then
|
||||
local file_url="${download_base_url}${file_path}"
|
||||
local local_path="${output_dir}${file_path}"
|
||||
download_file "$file_url" "$local_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Recursively process all folders
|
||||
for folder_path in "${folders[@]}"; do
|
||||
if [[ -n "$folder_path" ]]; then
|
||||
process_directory "$package_name" "$version" "$folder_path" "$output_dir" "$processed_dirs"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
|
||||
# Parse command line arguments
|
||||
if [ $# -lt 1 ] || [ $# -gt 3 ]; then
|
||||
echo "Usage: $0 <package-name> [version] [output-dir]"
|
||||
echo "Example: $0 \"@solidjs/signals\""
|
||||
echo "Example: $0 \"@solidjs/signals\" \"0.4.1\""
|
||||
echo "Example: $0 \"@solidjs/signals\" \"latest\""
|
||||
echo "Example: $0 \"@solidjs/signals\" \"latest\" \"./downloads\""
|
||||
echo "Example: $0 \"lodash\" \"4.17.21\" \"./downloads\""
|
||||
echo ""
|
||||
echo "Version defaults to 'latest' if not specified"
|
||||
echo "Set DEBUG=1 environment variable for verbose output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local package_name="$1"
|
||||
local version="${2:-latest}"
|
||||
|
||||
# Resolve latest version if needed (do this once at the start)
|
||||
local resolved_version
|
||||
if ! resolved_version=$(resolve_latest_version "$package_name" "$version"); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use resolved version for output directory
|
||||
local output_dir="${3:-./$(echo "${package_name}" | sed 's/@//g' | sed 's/\//-/g')/${resolved_version}}"
|
||||
|
||||
print_status "Starting download of package: $package_name@$version"
|
||||
if [[ "$version" == "latest" ]]; then
|
||||
print_status "Resolved to actual version: $resolved_version"
|
||||
fi
|
||||
print_status "Output directory: $output_dir"
|
||||
|
||||
# Check if the directory already exists and has content
|
||||
if [[ -d "$output_dir" ]] && [[ -n "$(ls -A "$output_dir" 2>/dev/null)" ]]; then
|
||||
print_warning "Directory already exists and is not empty: $output_dir"
|
||||
print_warning "Package $package_name@$resolved_version appears to already be downloaded."
|
||||
print_warning "Remove the directory or choose a different output location to proceed."
|
||||
return
|
||||
fi
|
||||
|
||||
# Create the base output directory
|
||||
create_dir "$output_dir"
|
||||
|
||||
# Start processing from the root directory using the resolved version
|
||||
process_directory "$package_name" "$resolved_version" "" "$output_dir" ""
|
||||
|
||||
print_success "Package download completed!"
|
||||
print_status "Files downloaded to: $output_dir"
|
||||
}
|
||||
|
||||
# Run the main function with all arguments
|
||||
# main "$@"
|
||||
|
||||
main "quickmatch-js"
|
||||
main "lean-qr"
|
||||
main "lightweight-charts"
|
||||
Reference in New Issue
Block a user