PageSourceSearch

https://woladen.de/favorite-metadata.mjs?v=20260626-favorites-web1

js woladen.de collected 2026-10-02 01:52:14 UTC 9,184 bytes, 293 lines download raw bytes

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.