1export const FAVORITE_METADATA_VERSION = 2; 2export const FAVORITES_V2_STORAGE_KEY = "woladen_favorites_v2"; 3export const FAVORITES_LEGACY_STORAGE_KEY = "woladen_favs"; 4export const FAVORITE_SOURCE_MANUAL = "manual"; 5export const FAVORITE_SOURCE_ROUTE = "route"; 6export const FAVORITE_SOURCE_MIGRATION = "migration"; 7export const FAVORITE_CATEGORY_UNCATEGORIZED = "__uncategorized__"; 8export const FAVORITE_FILTER_ALL = "__all__"; 9export const MAX_FAVORITE_CATEGORY_LENGTH = 48; 10export const MAX_FAVORITE_CATEGORIES = 12; 11export const MAX_FAVORITE_CATEGORY_SUGGESTIONS = 6; 12 13const VALID_SOURCES = new Set([ 14 FAVORITE_SOURCE_MANUAL, 15 FAVORITE_SOURCE_ROUTE, 16 FAVORITE_SOURCE_MIGRATION, 17]); 18 19function defaultNormalizeStationId(value) { 20 return String(value || "").trim(); 21} 22 23function nowIso(now = new Date()) { 24 const date = now instanceof Date ? now : new Date(now); 25 return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); 26} 27 28function cleanTimestamp(value, fallback) { 29 const text = String(value || "").trim(); 30 if (!text) { 31 return fallback; 32 } 33 const date = new Date(text); 34 return Number.isNaN(date.getTime()) ? fallback : text; 35} 36 37function normalizeSource(value, fallback = FAVORITE_SOURCE_MANUAL) { 38 const source = String(value || "").trim(); 39 return VALID_SOURCES.has(source) ? source : fallback; 40} 41 42export function normalizeFavoriteCategoryLabel(value) { 43 return String(value || "") 44 .replace(/\s+/g, " ") 45 .trim() 46 .slice(0, MAX_FAVORITE_CATEGORY_LENGTH); 47} 48 49export function normalizeFavoriteCategories(value, knownCategories = []) { 50 const displayByKey = new Map(); 51 knownCategories.forEach((category) => { 52 const label = normalizeFavoriteCategoryLabel(category); 53 const key = label.toLocaleLowerCase(); 54 if (label && !displayByKey.has(key)) { 55 displayByKey.set(key, label); 56 } 57 }); 58 59 const source = Array.isArray(value) ? value : []; 60 const categories = []; 61 const seen = new Set(); 62 source.forEach((item) => { 63 const label = normalizeFavoriteCategoryLabel(item); 64 const key = label.toLocaleLowerCase(); 65 if (!label || seen.has(key)) { 66 return; 67 } 68 categories.push(displayByKey.get(key) || label); 69 seen.add(key); 70 }); 71 return categories.slice(0, MAX_FAVORITE_CATEGORIES); 72} 73 74export function createEmptyFavoriteMetadata() { 75 return { 76 version: FAVORITE_METADATA_VERSION, 77 items: new Map(), 78 }; 79} 80 81export function getFavoriteCategories(metadata) { 82 const categories = []; 83 const seen = new Set(); 84 for (const item of metadata?.items?.values?.() || []) { 85 normalizeFavoriteCategories(item.categories, categories).forEach((category) => { 86 const key = category.toLocaleLowerCase(); 87 if (!seen.has(key)) { 88 categories.push(category); 89 seen.add(key); 90 } 91 }); 92 } 93 return categories; 94} 95 96export function normalizeFavoriteItem(value, options = {}) { 97 const normalizeStationId = options.normalizeStationId || defaultNormalizeStationId; 98 const fallbackNow = nowIso(options.now); 99 if (!value || typeof value !== "object") { 100 return null; 101 } 102 const stationId = normalizeStationId(value.station_id ?? value.stationId ?? value.id); 103 if (!stationId) { 104 return null; 105 } 106 const createdAt = cleanTimestamp(value.created_at ?? value.createdAt, fallbackNow); 107 const updatedAt = cleanTimestamp(value.updated_at ?? value.updatedAt, createdAt); 108 return { 109 station_id: stationId, 110 categories: normalizeFavoriteCategories(value.categories, options.knownCategories || []), 111 created_at: createdAt, 112 updated_at: updatedAt, 113 source: normalizeSource(value.source), 114 }; 115} 116 117export function normalizeFavoriteMetadata(value, options = {}) { 118 const metadata = createEmptyFavoriteMetadata(); 119 if (!value || typeof value !== "object" || Number(value.version) !== FAVORITE_METADATA_VERSION) { 120 return metadata; 121 } 122 123 const sourceItems = value.items && typeof value.items === "object" && !Array.isArray(value.items) 124 ? Object.values(value.items) 125 : []; 126 let knownCategories = []; 127 sourceItems.forEach((item) => { 128 const normalized = normalizeFavoriteItem(item, { ...options, knownCategories }); 129 if (!normalized) { 130 return; 131 } 132 knownCategories = getFavoriteCategories({ 133 items: new Map([...metadata.items, [normalized.station_id, normalized]]), 134 }); 135 metadata.items.set(normalized.station_id, normalized); 136 }); 137 return metadata; 138} 139 140export function parseStoredFavoriteMetadata(raw, options = {}) { 141 if (!raw || typeof raw !== "string") { 142 return null; 143 } 144 try { 145 const payload = JSON.parse(raw); 146 if (!payload || typeof payload !== "object" || Number(payload.version) !== FAVORITE_METADATA_VERSION) { 147 return null; 148 } 149 return normalizeFavoriteMetadata(payload, options); 150 } catch { 151 return null; 152 } 153} 154 155export function migrateLegacyFavorites(raw, options = {}) { 156 const normalizeStationId = options.normalizeStationId || defaultNormalizeStationId; 157 const timestamp = nowIso(options.now); 158 const metadata = createEmptyFavoriteMetadata(); 159 let values = []; 160 try { 161 values = raw && typeof raw === "string" ? JSON.parse(raw) : []; 162 } catch { 163 values = []; 164 } 165 if (!Array.isArray(values)) { 166 return metadata; 167 }
168 values.forEach((value) => { 169 const stationId = normalizeStationId(value); 170 if (!stationId || metadata.items.has(stationId)) { 171 return; 172 } 173 metadata.items.set(stationId, { 174 station_id: stationId, 175 categories: [], 176 created_at: timestamp, 177 updated_at: timestamp, 178 source: FAVORITE_SOURCE_MIGRATION, 179 }); 180 }); 181 return metadata; 182} 183 184export function serializeFavoriteMetadata(metadata) { 185 const items = {}; 186 const entries = Array.from(metadata?.items?.entries?.() || []) 187 .sort(([leftId], [rightId]) => leftId.localeCompare(rightId)); 188 entries.forEach(([stationId, item]) => { 189 items[stationId] = { 190 station_id: stationId, 191 categories: normalizeFavoriteCategories(item.categories, getFavoriteCategories(metadata)), 192 created_at: item.created_at, 193 updated_at: item.updated_at, 194 source: normalizeSource(item.source), 195 }; 196 }); 197 return JSON.stringify({ 198 version: FAVORITE_METADATA_VERSION, 199 items, 200 }); 201} 202 203export function getFavoriteStationIds(metadata) { 204 return new Set(Array.from(metadata?.items?.keys?.() || [])); 205} 206 207export function ensureFavoriteItem(metadata, stationId, options = {}) { 208 const id = defaultNormalizeStationId(stationId); 209 if (!id) { 210 return null; 211 } 212 const timestamp = nowIso(options.now); 213 const existing = metadata.items.get(id); 214 if (existing) { 215 return existing; 216 } 217 const item = { 218 station_id: id, 219 categories: [], 220 created_at: timestamp, 221 updated_at: timestamp, 222 source: normalizeSource(options.source, FAVORITE_SOURCE_MANUAL), 223 }; 224 metadata.items.set(id, item); 225 return item; 226} 227 228export function removeFavoriteItem(metadata, stationId) { 229 return metadata.items.delete(defaultNormalizeStationId(stationId)); 230} 231 232export function setFavoriteCategories(metadata, stationId, categories, options = {}) { 233 const item = options.create === false 234 ? metadata.items.get(defaultNormalizeStationId(stationId)) 235 : ensureFavoriteItem(metadata, stationId, options); 236 if (!item) { 237 return null; 238 } 239 item.categories = normalizeFavoriteCategories(categories, getFavoriteCategories(metadata)); 240 item.updated_at = nowIso(options.now); 241 return item; 242} 243 244export function addFavoriteCategory(metadata, stationId, category, options = {}) { 245 const item = ensureFavoriteItem(metadata, stationId, options); 246 if (!item) { 247 return null; 248 } 249 return setFavoriteCategories( 250 metadata, 251 stationId, 252 [...item.categories, category], 253 options, 254 ); 255} 256 257export function removeFavoriteCategory(metadata, stationId, category, options = {}) { 258 const item = metadata.items.get(defaultNormalizeStationId(stationId)); 259 if (!item) { 260 return null; 261 } 262 const key = normalizeFavoriteCategoryLabel(category).toLocaleLowerCase(); 263 item.categories = item.categories.filter( 264 (label) => label.toLocaleLowerCase() !== key, 265 ); 266 item.updated_at = nowIso(options.now); 267 return item; 268} 269 270export function favoriteCategorySuggestions(metadata, query, options = {}) { 271 const limit = Math.max(1, Number(options.limit || MAX_FAVORITE_CATEGORY_SUGGESTIONS)); 272 const currentKeys = new Set( 273 normalizeFavoriteCategories(options.exclude || []).map((category) => category.toLocaleLowerCase()), 274 ); 275 const normalizedQuery = normalizeFavoriteCategoryLabel(query).toLocaleLowerCase(); 276 const categories = getFavoriteCategories(metadata) 277 .filter((category) => !currentKeys.has(category.toLocaleLowerCase())) 278 .sort((left, right) => left.localeCompare(right, options.locale || undefined)); 279 if (!normalizedQuery) { 280 return categories.slice(0, limit); 281 } 282 const prefix = []; 283 const substring = []; 284 categories.forEach((category) => { 285 const key = category.toLocaleLowerCase(); 286 if (key.startsWith(normalizedQuery)) { 287 prefix.push(category); 288 } else if (key.includes(normalizedQuery)) { 289 substring.push(category); 290 } 291 }); 292 return [...prefix, ...substring].slice(0, limit); 293}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.