All files / src/web media-catalogue.js

100% Statements 83/83
89.79% Branches 44/49
100% Functions 24/24
100% Lines 62/62

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259                                12x                                                             561x       102x   53x         9x 9x                     12x   12x 13x 13x 13x                     12x 12x     7x 7x                       12x                   12x 11x       19x       13x     11x 11x                         26x                   12x                   12x                   12x                       9x 9x 4x 4x   9x         12x 12x 12x 12x 12x   12x 149x           293x 293x 288x 290x 121x 119x 115x                 620x 620x 160x 160x 293x 293x 178x   45x       400x       12x   12x               354x 354x 366x 366x   354x 183x              
import { Provenance, describe as describeHfe } from "../bbcdiscs.js";
import { Schemas, splitImage } from "../media-resolver.js";
import { findModel } from "../models.js";
 
/**
 * One shape for everything the media window can list, whichever source it
 * came from:
 *
 *   { ref, kind, title, publisher, detail, source, savesChanges, url?, requires? }
 *
 * `ref` is what loadDiscImage or loadTapeImage takes and what goes in the URL; `url` is a
 * page about the entry, when its source has one; `requires` is the machine the entry runs on,
 * from `machineRequirement`, when its source says.
 */
 
// `name` heads a chip; `phrase` sits mid-sentence in a slot's status line.
export const Sources = Object.freeze({
    builtin: { name: "Built in", phrase: "built in", title: "The example discs that ship with jsbeeb" },
    sth: { name: "STH archive", phrase: "STH archive", title: "The Stairway To Hell mirror" },
    hfe: {
        name: "HFE archive",
        phrase: "HFE archive",
        title: "Flux captures of real discs, with title, publisher, side and pitch",
    },
    hfeRebuilt: {
        name: "HFE rebuilt",
        phrase: "HFE archive",
        title: "Discs rebuilt from a sector dump: the data is right, the surface around it is inferred",
    },
    bitshifters: {
        name: "Bitshifters",
        phrase: "Bitshifters",
        title: "Demos and games released at bitshifters.github.io, each with the page that presents it",
    },
    gdrive: { name: "Google Drive", phrase: "Google Drive", title: "Your Google Drive; changes are kept there" },
    browser: {
        name: "This browser",
        phrase: "this browser",
        title: "Discs kept in this browser's storage; changes are kept",
    },
    session: {
        name: "This session",
        phrase: "a file opened this session",
        title: "Files opened this session; they cannot be named in the URL",
    },
});
 
export const sourceName = (source) => Sources[source]?.name ?? source;
 
/** Where a URL reference came from, in words, or null when the URL names nothing. */
export function sourceOf(ref) {
    if (!ref) return null;
    const schema = Schemas[splitImage(ref).schema];
    return Sources[schema?.source]?.phrase ?? schema?.phrase ?? null;
}
 
/** A descriptor for a bare reference, as the URL or the desktop menu gives one: known by its file name. */
export function describeRef(ref, kind) {
    const { schema, image } = splitImage(ref);
    return {
        ref,
        kind,
        title: image.split("/").pop(),
        publisher: "",
        detail: "",
        source: Schemas[schema]?.source ?? Schemas[schema]?.phrase ?? schema,
        savesChanges: false,
    };
}
 
const LocalDiscPrefix = "disc_";
 
const describeSth = (path, kind) => {
    const slash = path.lastIndexOf("/");
    const file = path.slice(slash + 1).replace(/\.zip$/i, "");
    return {
        ref: `sth:${path}`,
        kind,
        title: file,
        publisher: slash >= 0 ? path.slice(0, slash) : "",
        detail: "",
        source: "sth",
        savesChanges: false,
    };
};
 
export const describeSthDisc = (path) => describeSth(path, "disc");
export const describeSthTape = (path) => describeSth(path, "tape");
 
export function describeHfeEntry(file) {
    const { title, publisher, detail } = describeHfe(file);
    return {
        ref: `hfe:${file.path}`,
        kind: "disc",
        title,
        publisher,
        detail,
        source: file.provenance === Provenance.Reconstructed ? "hfeRebuilt" : "hfe",
        savesChanges: false,
    };
}
 
/** The machines a manifest may name as what a disc needs: model as the URL spells it, co-processor, user-facing name. */
export const MachineRequirements = Object.freeze({
    Master: Object.freeze({ model: "Master", coProcessor: false, name: "BBC Master 128" }),
    MasterTurbo: Object.freeze({
        model: "Master",
        coProcessor: true,
        name: "BBC Master 128 with a 65C102 co-processor",
    }),
});
 
/** A descriptor's `requires` for a manifest's machine name, or undefined for a name outside the table. */
export const machineRequirement = (machine) =>
    Object.hasOwn(MachineRequirements, machine) ? MachineRequirements[machine] : undefined;
 
/** Whether a machine is the one a requirement names: that model, with a Tube if and only if it says so. */
export function satisfiesRequirement(requires, { model, hasTube }) {
    return model === findModel(requires.model) && hasTube === requires.coProcessor;
}
 
// The site's manifest carries its own markup in a publisher or an author, a <span> or a link.
const stripTags = (text) => text.replace(/<[^>]*>/g, "");
 
export function describeBitshiftersEntry(file) {
    const detail = [file.type, file.machine, file.year, file.authors && stripTags(file.authors)];
    return {
        ref: `bitshifters:${file.path}`,
        kind: "disc",
        title: file.title || file.path,
        publisher: stripTags(file.publisher ?? ""),
        detail: detail.filter(Boolean).join(" ยท "),
        source: "bitshifters",
        savesChanges: false,
        url: file.url,
        requires: machineRequirement(file.machine),
    };
}
 
export const describeBuiltIn = (image) => ({
    ref: image.file,
    kind: "disc",
    title: image.name,
    publisher: "",
    detail: image.desc,
    source: "builtin",
    savesChanges: false,
});
 
export const describeDriveFile = (file) => ({
    ref: `gd:${file.id}/${file.name}`,
    kind: "disc",
    title: file.name,
    publisher: "",
    detail: "",
    source: "gdrive",
    savesChanges: file.capabilities?.canEdit !== false,
});
 
export const describeBrowserDisc = (name) => ({
    ref: `local:${name}`,
    kind: "disc",
    title: name,
    publisher: "",
    detail: "",
    source: "browser",
    savesChanges: true,
});
 
export const describeSessionFile = (name, kind) => ({
    ref: `session:${name}`,
    kind,
    title: name,
    publisher: "",
    detail: "opened this session",
    source: "session",
    savesChanges: false,
});
 
/** The discs held in the browser's storage, by name. */
export function browserDiscNames(storage = window.localStorage) {
    const names = [];
    for (let i = 0; i < storage.length; ++i) {
        const key = storage.key(i);
        if (key.startsWith(LocalDiscPrefix)) names.push(key.slice(LocalDiscPrefix.length));
    }
    return names.sort();
}
 
// How well one word of a query fits a descriptor: the title itself, a word of the title, somewhere
// in the title, or only in the publisher or detail. Zero is no fit.
const WholeTitle = 8;
const TitleStart = 4;
const TitleWordStart = 3;
const InTitle = 2;
const Elsewhere = 1;
 
const wordsOf = (text) =>
    text
        .toLowerCase()
        .split(/[^a-z0-9]+/)
        .filter(Boolean);
 
function scoreWord(descriptor, word) {
    const title = descriptor.title.toLowerCase();
    if (title === word) return WholeTitle;
    if (title.startsWith(word)) return TitleStart;
    if (wordsOf(title).some((titleWord) => titleWord.startsWith(word))) return TitleWordStart;
    if (title.includes(word)) return InTitle;
    if (`${descriptor.publisher} ${descriptor.detail}`.toLowerCase().includes(word)) return Elsewhere;
    return 0;
}
 
/**
 * How well a descriptor answers the typed query: zero when some word of the
 * query is nowhere in it, otherwise higher the closer the title itself is to
 * what was typed, so "Exile" outranks "CHT_Exile-Mapper" for "exil".
 */
export function scoreQuery(descriptor, query) {
    const words = query.toLowerCase().split(/\s+/).filter(Boolean);
    if (words.length === 0) return 1;
    let score = 0;
    for (const word of words) {
        const wordScore = scoreWord(descriptor, word);
        if (wordScore === 0) return 0;
        score += wordScore;
    }
    return score;
}
 
/** Whether a descriptor is what the typed query is looking for. */
export const matchesQuery = (descriptor, query) => scoreQuery(descriptor, query) > 0;
 
// Among equal matches with one title: the user's own discs, then the sources with metadata
// (the authors' own releases, the flux captures) before the one without.
const SourceRank = { browser: 1, gdrive: 1, session: 1, bitshifters: 2, hfe: 2, hfeRebuilt: 3, sth: 4 };
 
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
 
/**
 * The list's order for a query: the best matches first, then by title across every source so
 * that the same title from two archives sits together, the richer source first.
 */
export function compareForQuery(query) {
    // A sort asks for each score many times over; the query is fixed, so each is worked out once.
    const scores = new Map();
    const scoreOf = (d) => {
        if (!scores.has(d)) scores.set(d, scoreQuery(d, query));
        return scores.get(d);
    };
    return (a, b) =>
        scoreOf(b) - scoreOf(a) ||
        (b.source === "builtin") - (a.source === "builtin") ||
        collator.compare(a.title, b.title) ||
        (SourceRank[a.source] ?? 9) - (SourceRank[b.source] ?? 9) ||
        collator.compare(a.publisher, b.publisher) ||
        collator.compare(a.detail, b.detail);
}