chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import type { RecipeItem } from '~/types'
|
||||
|
||||
/**
|
||||
* Favorites and browsing history, plus the tombstones needed to sync deletions.
|
||||
*
|
||||
* Merging is a pure, commutative and idempotent function, so local data, cloud
|
||||
* data and imported files can be combined in any order and any number of times.
|
||||
*/
|
||||
|
||||
export const USER_DATA_VERSION = 1
|
||||
export const HISTORY_LIMIT = 200
|
||||
export const TOMBSTONE_LIMIT = 500
|
||||
export const TOMBSTONE_TTL = 180 * 24 * 60 * 60 * 1000
|
||||
|
||||
export interface FavoriteEntry { id: number, time: number }
|
||||
export interface FavoriteRemoval { id: number, time: number }
|
||||
export interface HistoryEntry { recipe: RecipeItem, time: number }
|
||||
/** Identifies one removed history record by its recipe name and visit time. */
|
||||
export interface HistoryRemoval { name: string, time: number }
|
||||
|
||||
export interface UserData {
|
||||
favorites: FavoriteEntry[]
|
||||
favoriteRemovals: FavoriteRemoval[]
|
||||
histories: HistoryEntry[]
|
||||
historyRemovals: HistoryRemoval[]
|
||||
/** Records visited at or before this time were cleared. */
|
||||
historyClearedAt: number
|
||||
}
|
||||
|
||||
export interface UserDataExportFile extends UserData {
|
||||
app: 'cook'
|
||||
version: typeof USER_DATA_VERSION
|
||||
exportedAt: number
|
||||
}
|
||||
|
||||
export function createEmptyUserData(): UserData {
|
||||
return {
|
||||
favorites: [],
|
||||
favoriteRemovals: [],
|
||||
histories: [],
|
||||
historyRemovals: [],
|
||||
historyClearedAt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeUserData(a: UserData, b: UserData, now = Date.now()): UserData {
|
||||
const historyClearedAt = Math.max(a.historyClearedAt, b.historyClearedAt)
|
||||
const { favorites, favoriteRemovals } = mergeFavorites(
|
||||
[...a.favorites, ...b.favorites],
|
||||
[...a.favoriteRemovals, ...b.favoriteRemovals],
|
||||
now,
|
||||
)
|
||||
const { histories, historyRemovals } = mergeHistories(
|
||||
[...a.histories, ...b.histories],
|
||||
[...a.historyRemovals, ...b.historyRemovals],
|
||||
historyClearedAt,
|
||||
now,
|
||||
)
|
||||
return { favorites, favoriteRemovals, histories, historyRemovals, historyClearedAt }
|
||||
}
|
||||
|
||||
export function isSameUserData(a: UserData, b: UserData): boolean {
|
||||
return JSON.stringify(toPlain(a)) === JSON.stringify(toPlain(b))
|
||||
}
|
||||
|
||||
function mergeFavorites(
|
||||
adds: FavoriteEntry[],
|
||||
removals: FavoriteRemoval[],
|
||||
now: number,
|
||||
): Pick<UserData, 'favorites' | 'favoriteRemovals'> {
|
||||
const latestAdd = latestBy(adds, entry => entry.id)
|
||||
const latestRemoval = latestBy(removals, entry => entry.id)
|
||||
|
||||
// Last write wins per recipe id; a removal at the same instant wins.
|
||||
const favorites = [...latestAdd.values()]
|
||||
.filter(add => (latestRemoval.get(add.id)?.time ?? -1) < add.time)
|
||||
.map(({ id, time }) => ({ id, time }))
|
||||
.sort((x, y) => x.time - y.time || x.id - y.id)
|
||||
const favoriteRemovals = pruneTombstones(
|
||||
[...latestRemoval.values()].filter(removal => (latestAdd.get(removal.id)?.time ?? -1) <= removal.time),
|
||||
now,
|
||||
)
|
||||
.map(({ id, time }) => ({ id, time }))
|
||||
.sort((x, y) => x.time - y.time || x.id - y.id)
|
||||
|
||||
return { favorites, favoriteRemovals }
|
||||
}
|
||||
|
||||
function mergeHistories(
|
||||
entries: HistoryEntry[],
|
||||
removals: HistoryRemoval[],
|
||||
clearedAt: number,
|
||||
now: number,
|
||||
): Pick<UserData, 'histories' | 'historyRemovals'> {
|
||||
const historyRemovals = pruneTombstones(
|
||||
uniqueBy(removals.filter(removal => removal.time > clearedAt), removal => historyKey(removal.name, removal.time)),
|
||||
now,
|
||||
)
|
||||
.map(({ name, time }) => ({ name, time }))
|
||||
.sort((x, y) => x.time - y.time || x.name.localeCompare(y.name))
|
||||
const removed = new Set(historyRemovals.map(removal => historyKey(removal.name, removal.time)))
|
||||
|
||||
// Oldest first, matching how the app appends visits. Recipes are rebuilt in a
|
||||
// canonical shape so local and cloud copies of the same visit compare equal.
|
||||
const histories = uniqueBy(
|
||||
entries.filter(entry => entry.time > clearedAt && !removed.has(historyKey(entry.recipe.name, entry.time))),
|
||||
entry => historyKey(entry.recipe.name, entry.time),
|
||||
)
|
||||
.map(entry => ({ recipe: readRecipe(entry.recipe) ?? entry.recipe, time: entry.time }))
|
||||
.sort((x, y) => x.time - y.time || x.recipe.name.localeCompare(y.recipe.name))
|
||||
.slice(-HISTORY_LIMIT)
|
||||
|
||||
return { histories, historyRemovals }
|
||||
}
|
||||
|
||||
function historyKey(name: string, time: number): string {
|
||||
return `${name}\u0000${time}`
|
||||
}
|
||||
|
||||
function latestBy<T extends { time: number }>(list: T[], key: (item: T) => number): Map<number, T> {
|
||||
const map = new Map<number, T>()
|
||||
for (const item of list) {
|
||||
const current = map.get(key(item))
|
||||
if (!current || item.time > current.time)
|
||||
map.set(key(item), item)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function uniqueBy<T>(list: T[], key: (item: T) => string): T[] {
|
||||
const map = new Map<string, T>()
|
||||
for (const item of list) {
|
||||
if (!map.has(key(item)))
|
||||
map.set(key(item), item)
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function pruneTombstones<T extends { time: number }>(list: T[], now: number): T[] {
|
||||
return list
|
||||
.filter(item => item.time >= now - TOMBSTONE_TTL)
|
||||
.sort((x, y) => y.time - x.time)
|
||||
.slice(0, TOMBSTONE_LIMIT)
|
||||
}
|
||||
|
||||
function toPlain(data: UserData): UserData {
|
||||
return {
|
||||
favorites: data.favorites.map(({ id, time }) => ({ id, time })),
|
||||
favoriteRemovals: data.favoriteRemovals.map(({ id, time }) => ({ id, time })),
|
||||
histories: data.histories.map(({ recipe, time }) => ({ recipe, time })),
|
||||
historyRemovals: data.historyRemovals.map(({ name, time }) => ({ name, time })),
|
||||
historyClearedAt: data.historyClearedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate untrusted data (a cloud document or an imported file).
|
||||
* Returns null when the shape is not recognisable; invalid entries are dropped.
|
||||
*/
|
||||
export function normalizeUserData(value: unknown): UserData | null {
|
||||
if (!isRecord(value))
|
||||
return null
|
||||
if (value.version !== undefined && value.version !== USER_DATA_VERSION)
|
||||
return null
|
||||
|
||||
return {
|
||||
favorites: readList(value.favorites, readFavorite),
|
||||
favoriteRemovals: readList(value.favoriteRemovals, readFavorite),
|
||||
histories: readList(value.histories, readHistory),
|
||||
historyRemovals: readList(value.historyRemovals, readHistoryRemoval),
|
||||
historyClearedAt: isTime(value.historyClearedAt) ? value.historyClearedAt : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function createUserDataExport(data: UserData, now = Date.now()): UserDataExportFile {
|
||||
return {
|
||||
app: 'cook',
|
||||
version: USER_DATA_VERSION,
|
||||
exportedAt: now,
|
||||
...toPlain(data),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUserDataExport(text: string): UserData | null {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
if (!isRecord(value) || value.app !== 'cook' || value.version !== USER_DATA_VERSION)
|
||||
return null
|
||||
return normalizeUserData(value)
|
||||
}
|
||||
|
||||
function readList<T>(value: unknown, read: (item: unknown) => T | null): T[] {
|
||||
if (!Array.isArray(value))
|
||||
return []
|
||||
return value.map(read).filter((item): item is T => item !== null)
|
||||
}
|
||||
|
||||
function readFavorite(value: unknown): FavoriteEntry | null {
|
||||
if (!isRecord(value) || !Number.isInteger(value.id) || (value.id as number) < 0 || !isTime(value.time))
|
||||
return null
|
||||
return { id: value.id as number, time: value.time }
|
||||
}
|
||||
|
||||
function readHistoryRemoval(value: unknown): HistoryRemoval | null {
|
||||
if (!isRecord(value) || !isName(value.name) || !isTime(value.time))
|
||||
return null
|
||||
return { name: value.name, time: value.time }
|
||||
}
|
||||
|
||||
function readHistory(value: unknown): HistoryEntry | null {
|
||||
if (!isRecord(value) || !isTime(value.time))
|
||||
return null
|
||||
const recipe = readRecipe(value.recipe)
|
||||
return recipe ? { recipe, time: value.time } : null
|
||||
}
|
||||
|
||||
function readRecipe(value: unknown): RecipeItem | null {
|
||||
if (!isRecord(value) || !isName(value.name))
|
||||
return null
|
||||
|
||||
const recipe: RecipeItem = {
|
||||
name: value.name,
|
||||
stuff: readStrings(value.stuff),
|
||||
tools: readStrings(value.tools),
|
||||
}
|
||||
for (const key of ['link', 'bv', 'difficulty'] as const) {
|
||||
if (typeof value[key] === 'string')
|
||||
(recipe as unknown as Record<string, unknown>)[key] = value[key]
|
||||
}
|
||||
for (const key of ['emojis', 'tags', 'methods'] as const) {
|
||||
if (Array.isArray(value[key]))
|
||||
(recipe as unknown as Record<string, unknown>)[key] = readStrings(value[key])
|
||||
}
|
||||
return recipe
|
||||
}
|
||||
|
||||
function readStrings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
function isName(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 200
|
||||
}
|
||||
|
||||
function isTime(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
Reference in New Issue
Block a user