openvisengine/src/lib/server/preset-tree.ts
2026-10-27 03:42:05 +00:00

89 lines
3 KiB
TypeScript
Executable file

// AVA — server-side preset folder-tree helpers.
//
// The preset library is a virtual file tree rooted at "root" with two
// structural areas: "root/embedded" (the shipped Winamp AVS ports) and
// "root/user created" (the user's own space). Every folder below those is
// fully manageable (create / rename / delete / export / import).
import { db } from '@/lib/db'
export const MAX_DEPTH = 8
export const MAX_SEGMENT = 80
/** Folders that anchor the tree — they can never be renamed or deleted. */
export const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created'])
export interface ParsedPath {
path: string
segments: string[]
}
/**
* Validate + normalize a folder path ("root/a/b"). Returns null when the path
* is malformed (wrong root, too deep, bad characters, empty segments).
*/
export function normalizeFolderPath(raw: unknown): ParsedPath | null {
if (typeof raw !== 'string') return null
const trimmed = raw.trim().replace(/\/+$/, '')
if (!trimmed) return null
const parts = trimmed
.split('/')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (parts.length === 0 || parts.length > MAX_DEPTH) return null
if (parts[0] !== 'root') return null
for (const seg of parts) {
if (seg.length > MAX_SEGMENT) return null
if (/[\u0000-\u001f\u007f]/.test(seg)) return null
if (seg === '.' || seg === '..') return null
}
return { path: parts.join('/'), segments: parts }
}
/** Validate a preset / folder display name (no separators, sane length). */
export function isValidName(raw: unknown, maxLen = 120): string | null {
if (typeof raw !== 'string') return null
const name = raw.trim()
if (!name || name.length > maxLen) return null
if (/[/\u0000-\u001f\u007f]/.test(name)) return null
return name
}
/** Parent path of a segment list: ["root","a","b"] → "root/a". */
export function parentOf(segments: string[]): string {
return segments.slice(0, -1).join('/')
}
/** Prisma where-clause matching presets in a folder and every folder below it. */
export function subtreeFilter(path: string) {
return {
OR: [{ folderPath: path }, { folderPath: { startsWith: `${path}/` } }],
}
}
/** Same, but for Folder rows (their column is `path`). */
export function subtreeFolderFilter(path: string) {
return {
OR: [{ path }, { path: { startsWith: `${path}/` } }],
}
}
/**
* Ensure Folder rows exist for the given paths AND all of their ancestors.
* Returns how many rows were created.
*/
export async function ensureFolderRows(paths: string[]): Promise<number> {
const wanted = new Set<string>()
for (const p of paths) {
const segs = p.split('/')
for (let i = 1; i <= segs.length; i++) wanted.add(segs.slice(0, i).join('/'))
}
const existing = await db.folder.findMany({
where: { path: { in: [...wanted] } },
select: { path: true },
})
for (const e of existing) wanted.delete(e.path)
if (wanted.size === 0) return 0
await db.folder.createMany({ data: [...wanted].map((path) => ({ path })) })
return wanted.size
}