89 lines
3 KiB
TypeScript
Executable file
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
|
|
}
|