155 lines
7.3 KiB
TypeScript
155 lines
7.3 KiB
TypeScript
/**
|
||
* 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件,
|
||
* 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。
|
||
* 代码问答还记工作区外的库文件,键是 `~/…` 或绝对路径(见 [outsideKey])。
|
||
*/
|
||
|
||
import { promises as fsp } from 'node:fs'
|
||
import * as path from 'node:path'
|
||
|
||
export type PathMap<T> = Record<string, T>
|
||
|
||
/** 表落在哪、叫什么、每条的值怎么认。 */
|
||
export interface PathMapFile<T> {
|
||
/** 相对工作区文件夹的位置(`/` 分隔),文件监听也用它 */
|
||
path: string
|
||
/** 读写出错时提示里怎么称呼它 */
|
||
label: string
|
||
/** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */
|
||
parse: (value: unknown) => T | undefined
|
||
/** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */
|
||
attachments?: (value: T) => string[]
|
||
}
|
||
|
||
/** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
|
||
export function normalizeKey(relativePath: string): string | undefined {
|
||
const key = path.posix.normalize(relativePath).replace(/\/+$/, '')
|
||
return key === '' || key === '.' || key === '..' || key.startsWith('../') || key.startsWith('/') ? undefined : key
|
||
}
|
||
|
||
/** @param rootPath 工作区文件夹的 `Uri.path`;@param targetPath 目录 / 文件的 `Uri.path` */
|
||
export function toPathKey(rootPath: string, targetPath: string): string | undefined {
|
||
return normalizeKey(path.posix.relative(rootPath, targetPath))
|
||
}
|
||
|
||
/**
|
||
* 工作区外的文件 / 目录(比如库源码)的键:在家目录下写成 `~/…`,别处写绝对路径。
|
||
* 工作区里的键是相对路径,开头不会是 `~/` 或 `/`,两边撞不上。
|
||
*
|
||
* @param targetPath 文件 / 目录的 `Uri.path`
|
||
* @param homePath 家目录的 `Uri.path`
|
||
*/
|
||
export function outsideKey(targetPath: string, homePath: string): string {
|
||
const underHome = toPathKey(homePath, targetPath)
|
||
return underHome === undefined ? path.posix.normalize(targetPath).replace(/\/+$/, '') : `~/${underHome}`
|
||
}
|
||
|
||
/** 工作区外的键(见 [outsideKey])在磁盘上的 `Uri.path`;工作区里的相对路径键返回 `undefined`。 */
|
||
export function outsidePath(key: string, homePath: string): string | undefined {
|
||
if (key.startsWith('~/'))
|
||
return path.posix.join(homePath, key.slice(2))
|
||
return key.startsWith('/') ? key : undefined
|
||
}
|
||
|
||
/** 读表时把一个键规范成统一写法:相对路径同 [normalizeKey],工作区外的 `~/…`、绝对路径(见 [outsideKey])也认;跑出家目录的、空的返回 `undefined`。 */
|
||
export function tableKey(raw: string): string | undefined {
|
||
if (raw.startsWith('~/')) {
|
||
const underHome = normalizeKey(raw.slice(2))
|
||
return underHome === undefined ? undefined : `~/${underHome}`
|
||
}
|
||
if (raw.startsWith('/')) {
|
||
const absolute = path.posix.normalize(raw).replace(/\/+$/, '')
|
||
return absolute === '' ? undefined : absolute
|
||
}
|
||
return normalizeKey(raw)
|
||
}
|
||
|
||
function isUnder(candidate: string, key: string): boolean {
|
||
return candidate === key || candidate.startsWith(`${key}/`)
|
||
}
|
||
|
||
/** key 自己和它下面各层的条目,键换成相对 key 的路径(key 自己是 `''`)。 */
|
||
export function entriesUnder<T>(map: PathMap<T>, key: string): PathMap<T> {
|
||
return Object.fromEntries(Object.entries(map)
|
||
.filter(([candidate]) => isUnder(candidate, key))
|
||
.map(([candidate, value]) => [candidate.slice(key.length + 1), value]))
|
||
}
|
||
|
||
/** 删掉 key 自己和它下面各层的条目(目录被删时连带里面的)。 */
|
||
export function dropEntries<T>(map: PathMap<T>, key: string): PathMap<T> {
|
||
return Object.fromEntries(Object.entries(map).filter(([candidate]) => !isUnder(candidate, key)))
|
||
}
|
||
|
||
/** 把 entriesUnder 取出的条目放到 key 下面。 */
|
||
export function placeEntries<T>(map: PathMap<T>, key: string, under: PathMap<T>): PathMap<T> {
|
||
const placed = Object.entries(under).map(([rest, value]) => [rest === '' ? key : `${key}/${rest}`, value])
|
||
return { ...map, ...Object.fromEntries(placed) }
|
||
}
|
||
|
||
/** 改名 / 挪位置:条目跟着走,目录连带里面各层的条目一起走。 */
|
||
export function moveEntries<T>(map: PathMap<T>, from: string, to: string): PathMap<T> {
|
||
return placeEntries(dropEntries(map, from), to, entriesUnder(map, from))
|
||
}
|
||
|
||
/** 两份表里有增、删、改的键;值按 JSON 写法比。 */
|
||
export function changedKeys<T>(before: PathMap<T>, after: PathMap<T>): string[] {
|
||
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||
return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
||
}
|
||
|
||
function fileIn<T>(workspaceRoot: string, file: PathMapFile<T>): string {
|
||
return path.join(workspaceRoot, ...file.path.split('/'))
|
||
}
|
||
|
||
/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键认不出(见 [tableKey])、值认不出的条目跳过。 */
|
||
export async function readPathMap<T>(workspaceRoot: string, file: PathMapFile<T>): Promise<PathMap<T>> {
|
||
let raw: string
|
||
try {
|
||
raw = await fsp.readFile(fileIn(workspaceRoot, file), 'utf8')
|
||
}
|
||
catch {
|
||
return {}
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(raw) as unknown
|
||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||
return {}
|
||
const map: PathMap<T> = {}
|
||
for (const [rawKey, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
|
||
const key = tableKey(rawKey)
|
||
const value = file.parse(rawValue)
|
||
if (key !== undefined && value !== undefined)
|
||
map[key] = value
|
||
}
|
||
return map
|
||
}
|
||
catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
/** 写工作区文件夹里的这份表(空表也写,文件留着):父目录按需创建,键排好序、两格缩进、末尾换行,git diff 稳定。 */
|
||
export async function writePathMap<T>(workspaceRoot: string, file: PathMapFile<T>, map: PathMap<T>): Promise<void> {
|
||
const target = fileIn(workspaceRoot, file)
|
||
await fsp.mkdir(path.dirname(target), { recursive: true })
|
||
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
||
await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
|
||
}
|
||
|
||
/** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */
|
||
export function detachedFiles<T>(file: PathMapFile<T>, before: PathMap<T>, after: PathMap<T>): string[] {
|
||
const { attachments } = file
|
||
if (!attachments)
|
||
return []
|
||
const kept = new Set(Object.values(after).flatMap(attachments))
|
||
return [...new Set(Object.values(before).flatMap(attachments))].filter(attachment => !kept.has(attachment))
|
||
}
|
||
|
||
/** 删掉工作区文件夹里的这些文件(相对路径);已经没了的不算错,跑到文件夹外面的路径不碰。 */
|
||
export async function removeFiles(workspaceRoot: string, relativePaths: readonly string[]): Promise<void> {
|
||
await Promise.all(relativePaths.flatMap((relativePath) => {
|
||
const key = normalizeKey(relativePath)
|
||
return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })]
|
||
}))
|
||
}
|