2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* @file This module contains functions for importing projects into the Project Manager.
|
2024-07-17 12:10:42 +03:00
|
|
|
*
|
|
|
|
* Eventually this module should be replaced with a new Project Manager API that supports
|
|
|
|
* importing projects.
|
|
|
|
* For now, we basically do the following:
|
|
|
|
* - if the project is already in the Project Manager's location, we just open it;
|
|
|
|
* - if the project is in a different location, we copy it to the Project Manager's location
|
|
|
|
* and open it.
|
2024-10-09 15:26:56 +03:00
|
|
|
* - if the project is a bundle, we extract it to the Project Manager's location and open it.
|
|
|
|
*/
|
2024-07-17 12:10:42 +03:00
|
|
|
import * as crypto from 'node:crypto'
|
|
|
|
import * as fs from 'node:fs'
|
|
|
|
import * as os from 'node:os'
|
|
|
|
import * as pathModule from 'node:path'
|
|
|
|
import type * as stream from 'node:stream'
|
|
|
|
|
|
|
|
import * as tar from 'tar'
|
|
|
|
|
|
|
|
import * as desktopEnvironment from './desktopEnvironment'
|
|
|
|
|
|
|
|
const logger = console
|
|
|
|
|
|
|
|
// =================
|
|
|
|
// === Constants ===
|
|
|
|
// =================
|
|
|
|
|
|
|
|
export const PACKAGE_METADATA_RELATIVE_PATH = 'package.yaml'
|
|
|
|
export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json'
|
|
|
|
|
|
|
|
// ======================
|
|
|
|
// === Project Import ===
|
|
|
|
// ======================
|
|
|
|
|
|
|
|
/** Upload the project from a bundle. */
|
|
|
|
export async function uploadBundle(
|
|
|
|
bundle: stream.Readable,
|
|
|
|
directory?: string | null,
|
|
|
|
name: string | null = null,
|
|
|
|
): Promise<string> {
|
|
|
|
directory ??= getProjectsDirectory()
|
|
|
|
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
|
|
|
|
const targetPath = generateDirectoryName(name ?? 'Project', directory)
|
|
|
|
fs.mkdirSync(targetPath, { recursive: true })
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
bundle.pipe(tar.extract({ cwd: targetPath })).on('finish', resolve)
|
|
|
|
})
|
|
|
|
const entries = fs.readdirSync(targetPath)
|
|
|
|
const firstEntry = entries[0]
|
|
|
|
// If the directory only contains one subdirectory, replace the directory with its sole
|
|
|
|
// subdirectory.
|
|
|
|
if (entries.length === 1 && firstEntry != null) {
|
|
|
|
if (fs.statSync(pathModule.join(targetPath, firstEntry)).isDirectory()) {
|
|
|
|
const temporaryDirectoryName = targetPath + `_${crypto.randomUUID().split('-')[0] ?? ''}`
|
|
|
|
fs.renameSync(targetPath, temporaryDirectoryName)
|
|
|
|
fs.renameSync(pathModule.join(temporaryDirectoryName, firstEntry), targetPath)
|
|
|
|
fs.rmdirSync(temporaryDirectoryName)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return bumpMetadata(targetPath, directory, name ?? null)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ================
|
|
|
|
// === Metadata ===
|
|
|
|
// ================
|
|
|
|
|
|
|
|
/** The Project Manager's metadata associated with a project. */
|
|
|
|
interface ProjectMetadata {
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* The ID of the project. It is only used in communication with project manager;
|
|
|
|
* it has no semantic meaning.
|
|
|
|
*/
|
2024-07-17 12:10:42 +03:00
|
|
|
readonly id: string
|
|
|
|
/** The project variant. This is currently always `UserProject`. */
|
|
|
|
readonly kind: 'UserProject'
|
|
|
|
/** The date at which the project was created, in RFC3339 format. */
|
|
|
|
readonly created: string
|
|
|
|
/** The date at which the project was last opened, in RFC3339 format. */
|
|
|
|
readonly lastOpened: string
|
|
|
|
}
|
|
|
|
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* A type guard function to check if an object conforms to the {@link ProjectMetadata} interface.
|
2024-07-17 12:10:42 +03:00
|
|
|
*
|
|
|
|
* This function checks if the input object has the required properties and correct types
|
|
|
|
* to match the {@link ProjectMetadata} interface. It can be used at runtime to validate that
|
|
|
|
* a given object has the expected shape.
|
|
|
|
* @param value - The object to check against the ProjectMetadata interface.
|
|
|
|
* @returns A boolean value indicating whether the object matches
|
2024-10-09 15:26:56 +03:00
|
|
|
* the {@link ProjectMetadata} interface.
|
|
|
|
*/
|
2024-07-17 12:10:42 +03:00
|
|
|
function isProjectMetadata(value: unknown): value is ProjectMetadata {
|
|
|
|
return typeof value === 'object' && value != null && 'id' in value && typeof value.id === 'string'
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Get the package name. */
|
|
|
|
function getPackageName(projectRoot: string) {
|
|
|
|
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
|
|
|
|
const contents = fs.readFileSync(path, { encoding: 'utf-8' })
|
|
|
|
const [, name] = contents.match(/^name: (.*)/) ?? []
|
|
|
|
return name ?? null
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Update the package name. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function updatePackageName(projectRoot: string, name: string) {
|
2024-07-17 12:10:42 +03:00
|
|
|
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
|
|
|
|
const contents = fs.readFileSync(path, { encoding: 'utf-8' })
|
|
|
|
const newContents = contents.replace(/^name: .*/, `name: ${name}`)
|
|
|
|
fs.writeFileSync(path, newContents)
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Create a project's metadata. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function createMetadata(): ProjectMetadata {
|
2024-07-17 12:10:42 +03:00
|
|
|
return {
|
|
|
|
id: generateId(),
|
|
|
|
kind: 'UserProject',
|
|
|
|
created: new Date().toISOString(),
|
|
|
|
lastOpened: new Date().toISOString(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Retrieve the project's metadata. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function getMetadata(projectRoot: string): ProjectMetadata | null {
|
2024-07-17 12:10:42 +03:00
|
|
|
const metadataPath = pathModule.join(projectRoot, PROJECT_METADATA_RELATIVE_PATH)
|
|
|
|
try {
|
|
|
|
const jsonText = fs.readFileSync(metadataPath, 'utf8')
|
|
|
|
const metadata: unknown = JSON.parse(jsonText)
|
|
|
|
return isProjectMetadata(metadata) ? metadata : null
|
|
|
|
} catch {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Write the project's metadata. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function writeMetadata(projectRoot: string, metadata: ProjectMetadata): void {
|
2024-07-17 12:10:42 +03:00
|
|
|
const metadataPath = pathModule.join(projectRoot, PROJECT_METADATA_RELATIVE_PATH)
|
|
|
|
fs.mkdirSync(pathModule.dirname(metadataPath), { recursive: true })
|
2024-07-24 15:08:16 +03:00
|
|
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 4))
|
2024-07-17 12:10:42 +03:00
|
|
|
}
|
|
|
|
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* Update the project's metadata.
|
2024-07-17 12:10:42 +03:00
|
|
|
* If the provided updater does not return anything, the metadata file is left intact.
|
|
|
|
*
|
2024-10-09 15:26:56 +03:00
|
|
|
* Returns the metadata returned from the updater function.
|
|
|
|
*/
|
2024-07-24 15:08:16 +03:00
|
|
|
function updateMetadata(
|
2024-07-17 12:10:42 +03:00
|
|
|
projectRoot: string,
|
|
|
|
updater: (initialMetadata: ProjectMetadata) => ProjectMetadata,
|
|
|
|
): ProjectMetadata {
|
|
|
|
const metadata = getMetadata(projectRoot)
|
|
|
|
const updatedMetadata = updater(metadata ?? createMetadata())
|
|
|
|
writeMetadata(projectRoot, updatedMetadata)
|
|
|
|
return updatedMetadata
|
|
|
|
}
|
|
|
|
|
|
|
|
// =========================
|
|
|
|
// === Project Directory ===
|
|
|
|
// =========================
|
|
|
|
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* Check if the given path represents the root of an Enso project.
|
|
|
|
* This is decided by the presence of the Project Manager's metadata.
|
|
|
|
*/
|
2024-07-24 15:08:16 +03:00
|
|
|
function isProjectRoot(candidatePath: string): boolean {
|
2024-07-17 12:10:42 +03:00
|
|
|
const projectJsonPath = pathModule.join(candidatePath, PROJECT_METADATA_RELATIVE_PATH)
|
|
|
|
try {
|
|
|
|
fs.accessSync(projectJsonPath, fs.constants.R_OK)
|
|
|
|
return true
|
|
|
|
} catch {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* Generate a name for a project using given base string. A suffix is added if there is a
|
2024-07-17 12:10:42 +03:00
|
|
|
* collision.
|
|
|
|
*
|
|
|
|
* For example `Name` will become `Name_1` if there's already a directory named `Name`.
|
|
|
|
* If given a name like `Name_1` it will become `Name_2` if there is already a directory named
|
|
|
|
* `Name_1`. If a path containing multiple components is given, only the last component is used
|
2024-10-09 15:26:56 +03:00
|
|
|
* for the name.
|
|
|
|
*/
|
2024-07-24 15:08:16 +03:00
|
|
|
function generateDirectoryName(name: string, directory = getProjectsDirectory()): string {
|
2024-07-17 12:10:42 +03:00
|
|
|
// Use only the last path component.
|
|
|
|
name = pathModule.parse(name).name
|
|
|
|
|
|
|
|
// If the name already consists a suffix, reuse it.
|
|
|
|
const matches = name.match(/^(.*)_(\d+)$/)
|
|
|
|
// Matches start with the whole match, so we need to skip it. Then come our two capture groups.
|
|
|
|
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
2024-10-15 14:32:52 +03:00
|
|
|
|
2024-07-17 12:10:42 +03:00
|
|
|
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
|
|
|
name = matchedName
|
|
|
|
}
|
|
|
|
|
2024-10-15 14:32:52 +03:00
|
|
|
return pathModule.join(directory, name)
|
2024-07-17 12:10:42 +03:00
|
|
|
}
|
|
|
|
|
2024-10-09 15:26:56 +03:00
|
|
|
/**
|
|
|
|
* Take a path to a file, presumably located in a project's subtree.Returns the path
|
|
|
|
* to the project's root directory or `null` if the file is not located in a project.
|
|
|
|
*/
|
2024-07-17 12:10:42 +03:00
|
|
|
export function getProjectRoot(subtreePath: string): string | null {
|
|
|
|
let currentPath = subtreePath
|
|
|
|
while (!isProjectRoot(currentPath)) {
|
|
|
|
const parent = pathModule.dirname(currentPath)
|
|
|
|
if (parent === currentPath) {
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
currentPath = parent
|
|
|
|
}
|
|
|
|
return currentPath
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Get the directory that stores Enso projects. */
|
|
|
|
export function getProjectsDirectory(): string {
|
|
|
|
const documentsPath = desktopEnvironment.DOCUMENTS
|
|
|
|
if (documentsPath === undefined) {
|
|
|
|
return pathModule.join(os.homedir(), 'enso', 'projects')
|
|
|
|
} else {
|
|
|
|
return pathModule.join(documentsPath, 'enso-projects')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Check if the given project is installed, i.e. can be opened with the Project Manager. */
|
|
|
|
export function isProjectInstalled(
|
|
|
|
projectRoot: string,
|
|
|
|
directory = getProjectsDirectory(),
|
|
|
|
): boolean {
|
|
|
|
const projectRootParent = pathModule.dirname(projectRoot)
|
|
|
|
// Should resolve symlinks and relative paths. Normalize before comparison.
|
|
|
|
return pathModule.resolve(projectRootParent) === pathModule.resolve(directory)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ==================
|
|
|
|
// === Project ID ===
|
|
|
|
// ==================
|
|
|
|
|
|
|
|
/** Generate a unique UUID for a project. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function generateId(): string {
|
2024-07-17 12:10:42 +03:00
|
|
|
return crypto.randomUUID()
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Update the project's ID to a new, unique value, and its last opened date to the current date. */
|
2024-07-24 15:08:16 +03:00
|
|
|
function bumpMetadata(projectRoot: string, parentDirectory: string, name: string | null): string {
|
2024-07-17 12:10:42 +03:00
|
|
|
if (name == null) {
|
|
|
|
const currentName = getPackageName(projectRoot) ?? ''
|
|
|
|
let index: number | null = null
|
|
|
|
const prefix = `${currentName} `
|
|
|
|
for (const sibling of fs.readdirSync(parentDirectory, { withFileTypes: true })) {
|
|
|
|
if (sibling.isDirectory()) {
|
|
|
|
try {
|
|
|
|
const siblingPath = pathModule.join(parentDirectory, sibling.name)
|
|
|
|
const siblingName = getPackageName(siblingPath)
|
|
|
|
if (siblingName === currentName) {
|
|
|
|
index = index ?? 2
|
|
|
|
} else if (siblingName != null && siblingName.startsWith(prefix)) {
|
|
|
|
const suffix = siblingName.replace(prefix, '')
|
|
|
|
const [, numberString] = suffix.match(/^\((\d+)\)/) ?? []
|
|
|
|
if (numberString != null) {
|
|
|
|
index = Math.max(index ?? 2, Number(numberString) + 1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
// Ignored - it is a directory but not a project.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
name = index == null ? currentName : `${currentName} (${index})`
|
|
|
|
}
|
|
|
|
updatePackageName(projectRoot, name)
|
|
|
|
return updateMetadata(projectRoot, (metadata) => ({
|
|
|
|
...metadata,
|
|
|
|
id: generateId(),
|
|
|
|
lastOpened: new Date().toISOString(),
|
|
|
|
})).id
|
|
|
|
}
|