2023-08-28 20:31:49 -04:00
|
|
|
import {
|
|
|
|
FileEntry,
|
|
|
|
createDir,
|
|
|
|
exists,
|
|
|
|
readDir,
|
|
|
|
writeTextFile,
|
|
|
|
} from '@tauri-apps/api/fs'
|
2023-10-17 12:31:14 -04:00
|
|
|
import { documentDir, homeDir, sep } from '@tauri-apps/api/path'
|
2023-08-15 21:56:24 -04:00
|
|
|
import { isTauri } from './isTauri'
|
2024-02-11 12:59:00 +11:00
|
|
|
import { type ProjectWithEntryPointMetadata } from 'lib/types'
|
2023-08-15 21:56:24 -04:00
|
|
|
import { metadata } from 'tauri-plugin-fs-extra-api'
|
|
|
|
|
2023-12-19 14:19:34 -05:00
|
|
|
const PROJECT_FOLDER = 'zoo-modeling-app-projects'
|
2023-08-15 21:56:24 -04:00
|
|
|
export const FILE_EXT = '.kcl'
|
|
|
|
export const PROJECT_ENTRYPOINT = 'main' + FILE_EXT
|
|
|
|
const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
|
|
|
|
export const MAX_PADDING = 7
|
2023-10-16 13:28:41 -04:00
|
|
|
const RELEVANT_FILE_TYPES = ['kcl']
|
2023-08-15 21:56:24 -04:00
|
|
|
|
|
|
|
// Initializes the project directory and returns the path
|
2023-08-28 20:31:49 -04:00
|
|
|
export async function initializeProjectDirectory(directory: string) {
|
2023-08-15 21:56:24 -04:00
|
|
|
if (!isTauri()) {
|
|
|
|
throw new Error(
|
|
|
|
'initializeProjectDirectory() can only be called from a Tauri app'
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-08-28 20:31:49 -04:00
|
|
|
if (directory) {
|
|
|
|
const dirExists = await exists(directory)
|
2023-08-15 21:56:24 -04:00
|
|
|
if (!dirExists) {
|
2023-08-28 20:31:49 -04:00
|
|
|
await createDir(directory, { recursive: true })
|
2023-08-15 21:56:24 -04:00
|
|
|
}
|
2023-08-28 20:31:49 -04:00
|
|
|
return directory
|
2023-08-15 21:56:24 -04:00
|
|
|
}
|
|
|
|
|
2023-09-12 18:46:35 -04:00
|
|
|
let docDirectory: string
|
|
|
|
try {
|
|
|
|
docDirectory = await documentDir()
|
|
|
|
} catch (e) {
|
2023-09-25 17:28:03 +10:00
|
|
|
console.log('error', e)
|
2024-01-04 04:54:07 -05:00
|
|
|
docDirectory = `${await homeDir()}Documents/` // for headless Linux (eg. Github Actions)
|
2023-09-12 18:46:35 -04:00
|
|
|
}
|
2023-08-15 21:56:24 -04:00
|
|
|
|
2023-08-28 20:31:49 -04:00
|
|
|
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
|
2023-08-15 21:56:24 -04:00
|
|
|
|
2023-08-28 20:31:49 -04:00
|
|
|
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
|
2023-08-15 21:56:24 -04:00
|
|
|
|
|
|
|
if (!defaultDirExists) {
|
2023-08-28 20:31:49 -04:00
|
|
|
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
|
2023-08-15 21:56:24 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
return INITIAL_DEFAULT_DIR
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
|
|
|
|
return (
|
|
|
|
fileOrDir.children?.length &&
|
|
|
|
fileOrDir.children.some((child) => child.name === PROJECT_ENTRYPOINT)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-08-28 20:31:49 -04:00
|
|
|
// Read the contents of a directory
|
|
|
|
// and return the valid projects
|
|
|
|
export async function getProjectsInDir(projectDir: string) {
|
|
|
|
const readProjects = (
|
|
|
|
await readDir(projectDir, {
|
|
|
|
recursive: true,
|
|
|
|
})
|
|
|
|
).filter(isProjectDirectory)
|
|
|
|
|
|
|
|
const projectsWithMetadata = await Promise.all(
|
|
|
|
readProjects.map(async (p) => ({
|
2023-10-17 12:31:14 -04:00
|
|
|
entrypointMetadata: await metadata(p.path + sep + PROJECT_ENTRYPOINT),
|
2023-08-28 20:31:49 -04:00
|
|
|
...p,
|
|
|
|
}))
|
|
|
|
)
|
|
|
|
|
|
|
|
return projectsWithMetadata
|
|
|
|
}
|
|
|
|
|
2023-10-16 13:28:41 -04:00
|
|
|
export const isHidden = (fileOrDir: FileEntry) =>
|
|
|
|
!!fileOrDir.name?.startsWith('.')
|
|
|
|
|
|
|
|
export const isDir = (fileOrDir: FileEntry) =>
|
|
|
|
'children' in fileOrDir && fileOrDir.children !== undefined
|
|
|
|
|
|
|
|
export function deepFileFilter(
|
|
|
|
entries: FileEntry[],
|
|
|
|
filterFn: (f: FileEntry) => boolean
|
|
|
|
): FileEntry[] {
|
|
|
|
const filteredEntries: FileEntry[] = []
|
|
|
|
for (const fileOrDir of entries) {
|
|
|
|
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
|
|
|
|
const filteredChildren = deepFileFilter(fileOrDir.children, filterFn)
|
|
|
|
if (filterFn(fileOrDir)) {
|
|
|
|
filteredEntries.push({
|
|
|
|
...fileOrDir,
|
|
|
|
children: filteredChildren,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
} else if (filterFn(fileOrDir)) {
|
|
|
|
filteredEntries.push(fileOrDir)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return filteredEntries
|
|
|
|
}
|
|
|
|
|
|
|
|
export function deepFileFilterFlat(
|
|
|
|
entries: FileEntry[],
|
|
|
|
filterFn: (f: FileEntry) => boolean
|
|
|
|
): FileEntry[] {
|
|
|
|
const filteredEntries: FileEntry[] = []
|
|
|
|
for (const fileOrDir of entries) {
|
|
|
|
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
|
|
|
|
const filteredChildren = deepFileFilterFlat(fileOrDir.children, filterFn)
|
|
|
|
if (filterFn(fileOrDir)) {
|
|
|
|
filteredEntries.push({
|
|
|
|
...fileOrDir,
|
|
|
|
children: filteredChildren,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
filteredEntries.push(...filteredChildren)
|
|
|
|
} else if (filterFn(fileOrDir)) {
|
|
|
|
filteredEntries.push(fileOrDir)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return filteredEntries
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read the contents of a project directory
|
|
|
|
// and return all relevant files and sub-directories recursively
|
|
|
|
export async function readProject(projectDir: string) {
|
|
|
|
const readFiles = await readDir(projectDir, {
|
|
|
|
recursive: true,
|
|
|
|
})
|
|
|
|
|
|
|
|
return deepFileFilter(readFiles, isRelevantFileOrDir)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Given a read project, return the number of .kcl files,
|
|
|
|
// both in the root directory and in sub-directories,
|
|
|
|
// and folders that contain at least one .kcl file
|
|
|
|
export function getPartsCount(project: FileEntry[]) {
|
|
|
|
const flatProject = deepFileFilterFlat(project, isRelevantFileOrDir)
|
|
|
|
|
|
|
|
const kclFileCount = flatProject.filter((f) =>
|
|
|
|
f.name?.endsWith(FILE_EXT)
|
|
|
|
).length
|
|
|
|
const kclDirCount = flatProject.filter((f) => f.children !== undefined).length
|
|
|
|
|
|
|
|
return {
|
|
|
|
kclFileCount,
|
|
|
|
kclDirCount,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Determines if a file or directory is relevant to the project
|
|
|
|
// i.e. not a hidden file or directory, and is a relevant file type
|
|
|
|
// or contains at least one relevant file (even if it's nested)
|
|
|
|
// or is a completely empty directory
|
|
|
|
export function isRelevantFileOrDir(fileOrDir: FileEntry) {
|
|
|
|
let isRelevantDir = false
|
|
|
|
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
|
|
|
|
isRelevantDir =
|
|
|
|
!isHidden(fileOrDir) &&
|
|
|
|
(fileOrDir.children.some(isRelevantFileOrDir) ||
|
|
|
|
fileOrDir.children.length === 0)
|
|
|
|
}
|
|
|
|
const isRelevantFile =
|
|
|
|
!isHidden(fileOrDir) &&
|
|
|
|
RELEVANT_FILE_TYPES.some((ext) => fileOrDir.name?.endsWith(ext))
|
|
|
|
|
|
|
|
return (
|
|
|
|
(isDir(fileOrDir) && isRelevantDir) || (!isDir(fileOrDir) && isRelevantFile)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Deeply sort the files and directories in a project like VS Code does:
|
|
|
|
// The main.kcl file is always first, then files, then directories
|
|
|
|
// Files and directories are sorted alphabetically
|
|
|
|
export function sortProject(project: FileEntry[]): FileEntry[] {
|
|
|
|
const sortedProject = project.sort((a, b) => {
|
|
|
|
if (a.name === PROJECT_ENTRYPOINT) {
|
|
|
|
return -1
|
|
|
|
} else if (b.name === PROJECT_ENTRYPOINT) {
|
|
|
|
return 1
|
|
|
|
} else if (a.children === undefined && b.children !== undefined) {
|
|
|
|
return -1
|
|
|
|
} else if (a.children !== undefined && b.children === undefined) {
|
|
|
|
return 1
|
|
|
|
} else if (a.name && b.name) {
|
|
|
|
return a.name.localeCompare(b.name)
|
|
|
|
} else {
|
|
|
|
return 0
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
return sortedProject.map((fileOrDir: FileEntry) => {
|
|
|
|
if ('children' in fileOrDir && fileOrDir.children !== undefined) {
|
|
|
|
return {
|
|
|
|
...fileOrDir,
|
|
|
|
children: sortProject(fileOrDir.children),
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return fileOrDir
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-08-15 21:56:24 -04:00
|
|
|
// Creates a new file in the default directory with the default project name
|
|
|
|
// Returns the path to the new file
|
|
|
|
export async function createNewProject(
|
2023-11-06 11:49:13 +11:00
|
|
|
path: string,
|
|
|
|
initCode = ''
|
2023-08-15 21:56:24 -04:00
|
|
|
): Promise<ProjectWithEntryPointMetadata> {
|
|
|
|
if (!isTauri) {
|
|
|
|
throw new Error('createNewProject() can only be called from a Tauri app')
|
|
|
|
}
|
|
|
|
|
|
|
|
const dirExists = await exists(path)
|
|
|
|
if (!dirExists) {
|
|
|
|
await createDir(path, { recursive: true }).catch((err) => {
|
|
|
|
console.error('Error creating new directory:', err)
|
|
|
|
throw err
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-11-06 11:49:13 +11:00
|
|
|
await writeTextFile(path + sep + PROJECT_ENTRYPOINT, initCode).catch(
|
|
|
|
(err) => {
|
|
|
|
console.error('Error creating new file:', err)
|
|
|
|
throw err
|
|
|
|
}
|
|
|
|
)
|
2023-08-15 21:56:24 -04:00
|
|
|
|
|
|
|
const m = await metadata(path)
|
|
|
|
|
|
|
|
return {
|
2023-10-17 12:31:14 -04:00
|
|
|
name: path.slice(path.lastIndexOf(sep) + 1),
|
2023-08-15 21:56:24 -04:00
|
|
|
path: path,
|
2023-10-16 13:28:41 -04:00
|
|
|
entrypointMetadata: m,
|
2023-08-15 21:56:24 -04:00
|
|
|
children: [
|
|
|
|
{
|
|
|
|
name: PROJECT_ENTRYPOINT,
|
2023-10-17 12:31:14 -04:00
|
|
|
path: path + sep + PROJECT_ENTRYPOINT,
|
2023-08-15 21:56:24 -04:00
|
|
|
children: [],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// create a regex to match the project name
|
|
|
|
// replacing any instances of "$n" with a regex to match any number
|
|
|
|
function interpolateProjectName(projectName: string) {
|
|
|
|
const regex = new RegExp(
|
|
|
|
projectName.replace(getPaddedIdentifierRegExp(), '([0-9]+)')
|
|
|
|
)
|
|
|
|
return regex
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns the next available index for a project name
|
|
|
|
export function getNextProjectIndex(projectName: string, files: FileEntry[]) {
|
|
|
|
const regex = interpolateProjectName(projectName)
|
|
|
|
const matches = files.map((file) => file.name?.match(regex))
|
|
|
|
const indices = matches
|
|
|
|
.filter(Boolean)
|
|
|
|
.map((match) => match![1])
|
|
|
|
.map(Number)
|
|
|
|
const maxIndex = Math.max(...indices, -1)
|
|
|
|
return maxIndex + 1
|
|
|
|
}
|
|
|
|
|
|
|
|
// Interpolates the project name with the next available index,
|
|
|
|
// padding the index with 0s if necessary
|
|
|
|
export function interpolateProjectNameWithIndex(
|
|
|
|
projectName: string,
|
|
|
|
index: number
|
|
|
|
) {
|
|
|
|
const regex = getPaddedIdentifierRegExp()
|
|
|
|
|
|
|
|
const matches = projectName.match(regex)
|
|
|
|
const padStartLength = Math.min(
|
|
|
|
matches !== null ? matches[1]?.length || 0 : 0,
|
|
|
|
MAX_PADDING
|
|
|
|
)
|
|
|
|
return projectName.replace(
|
|
|
|
regex,
|
|
|
|
index.toString().padStart(padStartLength + 1, '0')
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
export function doesProjectNameNeedInterpolated(projectName: string) {
|
|
|
|
return projectName.includes(INDEX_IDENTIFIER)
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeRegExpChars(string: string) {
|
|
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
|
}
|
|
|
|
|
|
|
|
function getPaddedIdentifierRegExp() {
|
|
|
|
const escapedIdentifier = escapeRegExpChars(INDEX_IDENTIFIER)
|
|
|
|
return new RegExp(`${escapedIdentifier}(${escapedIdentifier.slice(-1)}*)`)
|
|
|
|
}
|