2024-10-17 04:06:03 +02:00
|
|
|
import ThemeConfig, { MinimalThemeInformation } from "../../Models/ThemeConfig/ThemeConfig"
|
2024-09-11 17:31:38 +02:00
|
|
|
import { Store } from "../UIEventSource"
|
2024-09-10 02:19:55 +02:00
|
|
|
import UserRelatedState from "../State/UserRelatedState"
|
2024-09-11 17:31:38 +02:00
|
|
|
import themeOverview from "../../assets/generated/theme_overview.json"
|
2024-09-12 16:14:57 +02:00
|
|
|
import { OsmConnection } from "../Osm/OsmConnection"
|
2024-12-31 19:55:08 +01:00
|
|
|
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
|
2025-02-15 23:42:07 +01:00
|
|
|
import Fuse from "fuse.js"
|
|
|
|
|
import Constants from "../../Models/Constants"
|
|
|
|
|
import Locale from "../../UI/i18n/Locale"
|
|
|
|
|
import { Utils } from "../../Utils"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class ThemeSearchIndex {
|
|
|
|
|
|
|
|
|
|
private readonly themeIndex: Fuse<MinimalThemeInformation>
|
|
|
|
|
private readonly layerIndex: Fuse<{ id: string, description }>
|
|
|
|
|
|
|
|
|
|
constructor(language: string, themesToSearch?: MinimalThemeInformation[], layersToIgnore: string[] = []) {
|
|
|
|
|
const themes = themesToSearch ?? ThemeSearch.officialThemes?.themes
|
|
|
|
|
if (!themes) {
|
|
|
|
|
throw "No themes loaded. Did generate:layeroverview fail?"
|
|
|
|
|
}
|
|
|
|
|
const fuseOptions = {
|
|
|
|
|
ignoreLocation: true,
|
|
|
|
|
threshold: 0.2,
|
|
|
|
|
keys: [
|
|
|
|
|
{ name: "id", weight: 2 },
|
|
|
|
|
"title." + language,
|
|
|
|
|
"keywords." + language,
|
|
|
|
|
"shortDescription." + language
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.themeIndex = new Fuse(themes.filter(th => th.id !== "personal"), fuseOptions)
|
|
|
|
|
|
|
|
|
|
const toIgnore = new Set(layersToIgnore)
|
|
|
|
|
const layersAsList: { id: string, description: Record<string, string[]> }[] = []
|
|
|
|
|
for (const id in ThemeSearch.officialThemes.layers) {
|
|
|
|
|
if (Constants.isPriviliged(id)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (toIgnore.has(id)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const l: Record<string, string[]> = ThemeSearch.officialThemes.layers[id]
|
|
|
|
|
layersAsList.push({ id, description: l })
|
|
|
|
|
}
|
|
|
|
|
this.layerIndex = new Fuse(layersAsList, {
|
|
|
|
|
includeScore: true,
|
|
|
|
|
minMatchCharLength: 3,
|
|
|
|
|
ignoreLocation: true,
|
|
|
|
|
threshold: 0.02,
|
|
|
|
|
keys: ["id", "description." + language]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public search(text: string, limit?: number): MinimalThemeInformation[] {
|
|
|
|
|
const scored = this.searchWithScores(text)
|
|
|
|
|
let result = Array.from(scored.entries())
|
|
|
|
|
result.sort((a, b) => b[0] - a[0])
|
|
|
|
|
if (limit) {
|
|
|
|
|
result = result.slice(0, limit)
|
|
|
|
|
}
|
|
|
|
|
return result.map(e => ThemeSearch.officialThemesById.get(e[0]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public searchWithScores(text: string): Map<string, number> {
|
|
|
|
|
const result = new Map<string, number>()
|
|
|
|
|
const themeResults = this.themeIndex.search(text)
|
|
|
|
|
for (const themeResult of themeResults) {
|
|
|
|
|
result.set(themeResult.item.id, themeResult.score)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const layerResults = this.layerIndex.search(text)
|
|
|
|
|
|
|
|
|
|
for (const layer of layerResults) {
|
|
|
|
|
const matchingThemes = ThemeSearch.layersToThemes.get(layer.item.id)
|
|
|
|
|
const score = layer.score
|
|
|
|
|
matchingThemes?.forEach(th => {
|
|
|
|
|
const previous = result.get(th.id) ?? 10000
|
|
|
|
|
result.set(th.id, Math.min(previous, score * 5))
|
|
|
|
|
})
|
|
|
|
|
}
|
2024-09-11 17:31:38 +02:00
|
|
|
|
2025-02-15 23:42:07 +01:00
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Builds a search index containing all public and visited themes, but ignoring the layers loaded by the current theme
|
|
|
|
|
*/
|
|
|
|
|
public static fromState(state: { osmConnection: OsmConnection; theme: ThemeConfig }): Store<ThemeSearchIndex> {
|
|
|
|
|
const layersToIgnore = state.theme.layers.filter((l) => l.isNormal()).map((l) => l.id)
|
|
|
|
|
const knownHidden: Store<string[]> = UserRelatedState.initDiscoveredHiddenThemes(
|
|
|
|
|
state.osmConnection
|
|
|
|
|
).map((list) => Utils.Dedup(list))
|
|
|
|
|
const otherThemes: MinimalThemeInformation[] = ThemeSearch.officialThemes.themes.filter(
|
|
|
|
|
(th) => th.id !== state.theme.id
|
|
|
|
|
)
|
|
|
|
|
return Locale.language.map(language => {
|
|
|
|
|
const themes = otherThemes.concat(...knownHidden.data.map(id => ThemeSearch.officialThemesById.get(id)))
|
|
|
|
|
return new ThemeSearchIndex(language, themes, layersToIgnore)
|
|
|
|
|
},
|
|
|
|
|
[knownHidden]
|
|
|
|
|
)
|
|
|
|
|
}
|
2024-09-11 17:31:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default class ThemeSearch {
|
|
|
|
|
public static readonly officialThemes: {
|
2024-10-19 14:44:55 +02:00
|
|
|
themes: MinimalThemeInformation[]
|
2024-09-11 17:31:38 +02:00
|
|
|
layers: Record<string, Record<string, string[]>>
|
2024-10-19 14:44:55 +02:00
|
|
|
} = <any>themeOverview
|
|
|
|
|
public static readonly officialThemesById: Map<string, MinimalThemeInformation> = new Map<
|
|
|
|
|
string,
|
|
|
|
|
MinimalThemeInformation
|
|
|
|
|
>()
|
2025-02-15 23:42:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* For every layer id, states which themes use the layer
|
|
|
|
|
*/
|
|
|
|
|
public static readonly layersToThemes: Map<string, MinimalThemeInformation[]> = new Map()
|
2024-09-11 17:31:38 +02:00
|
|
|
static {
|
|
|
|
|
for (const th of ThemeSearch.officialThemes.themes ?? []) {
|
|
|
|
|
ThemeSearch.officialThemesById.set(th.id, th)
|
2025-02-15 23:42:07 +01:00
|
|
|
for (const layer of th.layers) {
|
|
|
|
|
let list = ThemeSearch.layersToThemes.get(layer)
|
|
|
|
|
if (!list) {
|
|
|
|
|
list = []
|
|
|
|
|
ThemeSearch.layersToThemes.set(layer, list)
|
|
|
|
|
}
|
|
|
|
|
list.push(th)
|
|
|
|
|
}
|
2024-09-11 17:31:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
2024-08-22 02:54:46 +02:00
|
|
|
|
2024-10-19 14:44:55 +02:00
|
|
|
public static createUrlFor(layout: { id: string }, state?: { layoutToUse?: { id } }): string {
|
2024-09-11 17:31:38 +02:00
|
|
|
if (layout === undefined) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
if (layout.id === undefined) {
|
|
|
|
|
console.error("ID is undefined for layout", layout)
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (layout.id === state?.layoutToUse?.id) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = window.location.pathname
|
|
|
|
|
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
|
|
|
|
|
path = path.substr(0, path.lastIndexOf("/"))
|
|
|
|
|
// Path will now contain '/dir/dir', or empty string in case of nothing
|
|
|
|
|
if (path === "") {
|
|
|
|
|
path = "."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
|
2025-02-10 02:04:58 +01:00
|
|
|
if (
|
|
|
|
|
(location.hostname === "localhost" && !AndroidPolyfill.inAndroid.data) ||
|
|
|
|
|
location.hostname === "127.0.0.1"
|
|
|
|
|
) {
|
2024-09-11 17:31:38 +02:00
|
|
|
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) {
|
|
|
|
|
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${linkPrefix}`
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-24 17:23:44 +02:00
|
|
|
|
2024-08-22 02:54:46 +02:00
|
|
|
}
|