// Pure key/value formatting helpers shared by the shell UI.
//
// Framework-free and side-effect-free so they can be unit-tested under
// plain node (see test/keys.test.js) without loading React/htm.
//
// z-base-32 ⇄ hex drive keys are shown in both encodings
// formatBytes human-readable cached-bytes display
// shortKey truncated key for compact labels
// normalizeUrl URL-bar input → canonical hyper://… URL
const Z32_ALPHABET = 'ybndrfg8ejkmcpqxot1uwisza345h769'
const Z32_REVERSE = (() => {
const map = new Map()
for (let i = 0; i < Z32_ALPHABET.length; i++) map.set(Z32_ALPHABET[i], i)
return map
})()
export function hexToBytes (hex) {
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) return null
const out = new Uint8Array(hex.length / 2)
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return out
}
export function bytesToHex (bytes) {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
export function z32EncodeBytes (bytes) {
const max = bytes.byteLength * 8
let s = ''
for (let p = 0; p < max; p += 5) {
const i = p >>> 3
const j = p & 7
if (j <= 3) {
s += Z32_ALPHABET[(bytes[i] >>> (3 - j)) & 0b11111]
continue
}
const of = j - 3
const high = (bytes[i] << of) & 0b11111
const low = (i + 1 >= bytes.byteLength ? 0 : bytes[i + 1]) >>> (8 - of)
s += Z32_ALPHABET[high | low]
}
return s
}
export function z32DecodeToBytes (s) {
const clean = String(s || '').toLowerCase()
const out = new Uint8Array(Math.ceil(clean.length * 5 / 8))
let pb = 0
let ps = 0
const quintet = () => {
const ch = clean[ps++]
if (!Z32_REVERSE.has(ch)) throw new Error('invalid z-base-32')
return Z32_REVERSE.get(ch)
}
const r = clean.length & 7
const q = (clean.length - r) / 8
for (let i = 0; i < q; i++) {
const a = quintet(), b = quintet(), c = quintet(), d = quintet()
const e = quintet(), f = quintet(), g = quintet(), h = quintet()
out[pb++] = (a << 3) | (b >>> 2)
out[pb++] = ((b & 0b11) << 6) | (c << 1) | (d >>> 4)
out[pb++] = ((d & 0b1111) << 4) | (e >>> 1)
out[pb++] = ((e & 0b1) << 7) | (f << 2) | (g >>> 3)
out[pb++] = ((g & 0b111) << 5) | h
}
if (r === 0) return out.subarray(0, pb)
const a = quintet(), b = quintet()
out[pb++] = (a << 3) | (b >>> 2)
if (r <= 2) return out.subarray(0, pb)
const c = quintet(), d = quintet()
out[pb++] = ((b & 0b11) << 6) | (c << 1) | (d >>> 4)
if (r <= 4) return out.subarray(0, pb)
const e = quintet()
out[pb++] = ((d & 0b1111) << 4) | (e >>> 1)
if (r <= 5) return out.subarray(0, pb)
const f = quintet(), g = quintet()
out[pb++] = ((e & 0b1) << 7) | (f << 2) | (g >>> 3)
if (r <= 7) return out.subarray(0, pb)
const h = quintet()
out[pb++] = ((g & 0b111) << 5) | h
return out.subarray(0, pb)
}
export function z32FromHex (hex) {
const bytes = hexToBytes(hex)
return bytes ? z32EncodeBytes(bytes) : null
}
export function hexFromZ32 (s) {
try {
const bytes = z32DecodeToBytes(s)
return bytes.length === 32 ? bytesToHex(bytes) : null
} catch {
return null
}
}
export function formatBytes (value) {
const bytes = Number(value) || 0
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let n = bytes / 1024
let unit = units[0]
for (let i = 1; i < units.length && n >= 1024; i++) {
n /= 1024
unit = units[i]
}
return `${n >= 10 ? n.toFixed(1) : n.toFixed(2)} ${unit}`
}
export function shortKey (k) {
if (!k || typeof k !== 'string') return ''
if (k.length <= 16) return k
return k.slice(0, 8) + '…' + k.slice(-6)
}
/**
* Normalize URL-bar input to a navigable absolute URL.
* Accepts Hyper content keys, https?/http URLs, and bare hostnames
* (example.com → https://example.com). Bare words without a dot still map
* to hyper:// for the name-resolver path.
*/
export function normalizeUrl (raw) {
const s = String(raw || '').trim()
if (!s) return null
if (/^hyper:\/\//i.test(s)) return s
if (/^https?:\/\//i.test(s)) return s
// Remote native-app and local-file launch schemes are not browser targets.
if (/^(?:pear|file):\/\//i.test(s)) return null
if (/^[0-9a-f]{64}$/i.test(s)) return `hyper://${s.toLowerCase()}/`
if (/^[13-9a-km-uw-z]{52}$/i.test(s)) return `hyper://${s}/`
// Public hostname (optionally with path/port) → https clearnet
if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#].*)?$/i.test(s)) {
return `https://${s.replace(/^\/+/, '')}`
}
if (s.includes('/')) return s
return `hyper://${s}`
}
/** True when the URL is non-loopback http(s) clearnet. */
export function isClearnetUrl (raw) {
try {
const u = new URL(String(raw || '').trim())
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
const host = (u.hostname || '').toLowerCase()
return host !== '127.0.0.1' && host !== 'localhost' && host !== '[::1]'
} catch {
return false
}
}
export function driveKeyFromHyperRef (raw) {
const s = String(raw || '').trim()
if (!s) return null
const rest = s.replace(/^hyper:\/\//i, '')
const key = rest.split('/')[0].trim()
if (/^[0-9a-f]{64}$/i.test(key)) return key.toLowerCase()
if (/^[13-9a-km-uw-z]{52}$/i.test(key)) return hexFromZ32(key)
return null
}
export function normalizeNameTarget (raw) {
const s = String(raw || '').trim()
if (!s) return null
if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase()
if (s.length <= 300 && /^hyper:\/\/.+/i.test(s)) return s
return null
}
// Naming Phase N1 — true when `raw` is a bare name token the resolver should
// try BEFORE URL handling: a single word like "keet" or "pear-pass", not a
// drive key, domain, path, or scheme. A cheap pre-filter so the URL bar only
// issues CMD_NAME_RESOLVE for plausible names (the backend resolver still
// returns null for anything unknown, so this never changes correctness — only
// avoids an RPC on ordinary navigations). Rejects keys explicitly so a typed
// 64-hex/52-z32 key always goes straight to drive loading.
export function looksLikeName (raw) {
const s = String(raw || '').normalize('NFKC').trim()
if (!/^[\p{L}\p{N}][\p{L}\p{N}_-]{0,127}$/u.test(s)) return false // single token: no dot/slash/scheme/space
if (/^[0-9a-f]{64}$/i.test(s)) return false // 64-char hex drive key
if (/^[13-9a-km-uw-z]{52}$/i.test(s)) return false // 52-char z-base-32 key
return true
}
// Route a catalog reference to the right backend loader by URL scheme:
// bare key / hyper:// → Hyperdrive catalog (CMD_LOAD_CATALOG)
// hyperbee:// → Hyperbee catalog (CMD_LOAD_CATALOG_BEE)
// autobee:// → Autobase collab catalog (CMD_LOAD_CATALOG_AUTOBEE)
// sheets:// → schema-sheets catalog (CMD_SHEETS_LOAD)
// hiveindex:// → relay index room catalog (CMD_LOAD_CATALOG_INDEX)
// Returns the scheme-stripped key plus `bee`/`autobee` flags, or null for
// empty input. (`bee` stays for back-compat; `kind` is the canonical field.)
export function parseCatalogRef (raw) {
const s = String(raw || '').trim()
if (!s) return null
const scheme = (s.match(/^([a-z][a-z0-9+.-]*):\/\//i)?.[1] || 'hyper').toLowerCase()
const kind = scheme === 'autobee'
? 'autobee'
: scheme === 'hyperbee'
? 'hyperbee'
: scheme === 'sheets'
? 'sheets'
: scheme === 'hiveindex'
? 'hiveindex'
: 'drive'
const key = s.replace(/^(autobee|hyperbee|hiveindex|sheets|hyper):\/\//i, '').replace(/\/+$/, '').trim()
if (!key) return null
return { key, bee: kind === 'hyperbee', autobee: kind === 'autobee', kind }
}
// Convert a user-facing catalog reference (hyperbee://<key>, autobee://<key>,
// sheets://<z32>, hiveindex://<z32>, hyper://<key>, or bare key) into the backend
// cache key used by listCatalogs().
// Also accepts an already-materialized cache key such as `bee:<key>`.
export function catalogCacheKeyForRef (raw) {
const s = String(raw || '').trim()
if (!s) return ''
if (/^(bee|sheets|hiveindex|autobee):(?!\/\/)/i.test(s)) return s
const parsed = parseCatalogRef(s)
if (!parsed) return s
if (parsed.autobee) return `autobee:${parsed.key}`
if (parsed.bee) return `bee:${parsed.key}`
if (parsed.kind === 'sheets' || parsed.kind === 'hiveindex') {
const keyHex = hexFromZ32(parsed.key)
return `${parsed.kind}:${keyHex || parsed.key}`
}
return parsed.key
}
// Parse a multi-device sync pairing invite: `sync://<key>:<encKey>` (the bare
// `<key>:<encKey>` form is also accepted). Both halves are 64-hex — the base
// bootstrap key and the encryption key. Returns { key, encKey } lowercased, or
// null if malformed. The pair is a sensitive capability: it IS the key to the
// user's encrypted, synced bookmarks, so anyone holding it can read them.
export function parseSyncInvite (raw) {
const s = String(raw || '').trim().replace(/^sync:\/\//i, '').replace(/\/+$/, '')
const m = s.match(/^([0-9a-f]{64}):([0-9a-f]{64})$/i)
if (!m) return null
return { key: m[1].toLowerCase(), encKey: m[2].toLowerCase() }
}
// Build the shareable pairing invite from a sync base key + encryption key.
// Returns '' if either half is not 64-hex (so callers can disable a Copy
// button rather than offer a malformed invite).
export function formatSyncInvite (key, encKey) {
const k = String(key || '').trim().toLowerCase()
const e = String(encKey || '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(k) || !/^[0-9a-f]{64}$/.test(e)) return ''
return `sync://${k}:${e}`
}
// Parse a `pearname://<name>` URL → the bare registry name (or null). Only a
// loose well-formedness gate — the backend name-normalize.cjs does the
// authoritative NFKC + confusable folding; this just strips the scheme so go()
// can resolve it via CMD_NAME_RESOLVE like a typed bare name. MAX_NAME=253.
export function parsePearname (raw) {
const s = String(raw || '').trim().replace(/^pearname:\/\//i, '').replace(/\/+$/, '')
return /^[^\s/]{1,253}$/.test(s) ? s : null
}