@simplepg/repo
Version:
SimplePage repository
413 lines (374 loc) • 13.9 kB
JavaScript
import { concat } from 'uint8arrays/concat'
import * as dagPb from '@ipld/dag-pb'
import all from 'it-all'
import { CID } from 'multiformats/cid'
import { addFile, rm, ls, lsFull, assert, CidSet, cp } from '@simplepg/common'
import { CHANGE_TYPE } from './constants.js'
export const FILES_FOLDER = '_files'
export const ARTIFACTS_FOLDER = '.artifacts'
const CHANGE_ROOT_KEY = 'spg_files_change_root'
/**
* A class for managing file operations in a SimplePage repository.
* Provides methods for listing, adding, and removing files with staging support.
*
* @param {object} fs - The unixfs filesystem instance.
* @param {object} blockstore - The blockstore instance.
* @param {function} ensureRepoData - A function to ensure the repo data is loaded.
*/
export class Files {
#ensureRepoData
#blockstore
#fs
#root
#changeRoot
#dservice
#storage
constructor(fs, blockstore, dservice, ensureRepoData, storage) {
this.#fs = fs
this.#blockstore = blockstore
this.#dservice = dservice
this.#ensureRepoData = ensureRepoData
this.#storage = storage
this.#root = null
this.#changeRoot = null
}
/**
* Sets the repo root CID for file operations.
* @param {CID} root - The root CID to use for file operations.
*/
async unsafeSetRepoRoot(root) {
// Check if /_files exists in the root
const refs = await ls(this.#blockstore, root)
const filesCid = refs.find(([name]) => name === FILES_FOLDER)?.[1]
this.#root = filesCid || (await this.#fs.addDirectory())
await this.#initializeChangeRoot()
}
/**
* Initializes the changeRoot from storage or creates a new one.
*/
async #initializeChangeRoot() {
const storedChangeRoot = JSON.parse(await this.#storage.getItem(CHANGE_ROOT_KEY))
const changeRoot = storedChangeRoot ? CID.parse(storedChangeRoot.changeRoot) : this.#root
this.#changeRoot = changeRoot
}
/**
* Returns true if the file changes are based on an old repo root.
* @returns {boolean} Whether the file changes are based on an old repo root.
*/
async isOutdated() {
await this.#isReady()
const storedChangeRoot = JSON.parse(await this.#storage.getItem(CHANGE_ROOT_KEY))
if (!storedChangeRoot) {
return false
}
return storedChangeRoot.root !== this.#root.toString() && storedChangeRoot.changeRoot !== this.#root.toString()
}
/**
* Saves the changeRoot to storage.
*/
async #setChangeRoot(changeRoot) {
this.#changeRoot = changeRoot
await this.#storage.setItem(CHANGE_ROOT_KEY, JSON.stringify({
root: this.#root.toString(),
changeRoot: changeRoot.toString()
}))
}
/**
* Checks if a file or folder exists in the filesystem.
* @param {string} path - The file path.
* @returns {Promise<CID | undefined>} The file entry CID if found, undefined if not found.
*/
async #fileExists(path, inChangeRoot = false) {
const split = path.split('/').filter(Boolean)
let ref = ['', inChangeRoot ? this.#changeRoot : this.#root]
for (const path of split) {
const refs = await ls(this.#blockstore, ref[1])
ref = refs.find(f => f[0] === path)
if (!ref) return undefined
}
return ref[1]
}
async #isReady() {
await this.#ensureRepoData()
if (!this.#root) {
throw new Error('Root not set. Call unsafeSetRoot() first.')
}
}
async #ensureContent(cid) {
if (!(await this.#blockstore.has(cid))) {
const response = await this.#dservice.fetch(`/file?cid=${cid.toString()}`)
if (response.status === 200) {
const content = new Uint8Array(await response.arrayBuffer())
await this.#blockstore.put(cid, content)
return content
}
throw new Error('Failed to fetch file from dservice')
}
}
/**
* Lists the contents of a directory.
* @param {string} path - The path of the directory to list.
* @returns {Promise<UnixFSEntry[]>} The list of contents of the directory.
*/
async ls(path) {
await this.#isReady()
const refsByPath = async (pathSplit, refs) => {
for (const path of pathSplit) {
const loc = refs.find(({ Name }) => Name === path)
if (!loc) return []
await this.#ensureContent(loc.Hash)
refs = await lsFull(this.#blockstore, loc.Hash)
}
return refs
}
// Get entries from original filesystem
const pathSplit = path.split('/').filter(Boolean)
let refs = await lsFull(this.#blockstore, this.#root)
refs = await refsByPath(pathSplit, refs)
// Get entries from change filesystem
let changeRefs = await lsFull(this.#blockstore, this.#changeRoot)
changeRefs = await refsByPath(pathSplit, changeRefs)
// Combine entries from both filesystems
const allEntries = new Map()
const refToEntry = (ref, fullPath, stat, change) => ({
name: ref.Name,
cid: ref.Hash,
size: ref.Tsize,
path: fullPath,
type: stat.type === 'directory' ? 'directory' : 'file',
change: change
})
// Add original entries
for (const ref of refs) {
const fullPath = [...pathSplit, ref.Name].join('/')
await this.#ensureContent(ref.Hash)
const stat = await this.#fs.stat(ref.Hash)
// set all changes to delete, next loop will set the correct change if file still exists in changeRoot
allEntries.set(ref.Name, refToEntry(ref, fullPath, stat, CHANGE_TYPE.DELETE))
}
// Add or update with change entries
for (const ref of changeRefs) {
const fullPath = [...pathSplit, ref.Name].join('/')
const entry = allEntries.get(ref.Name)
if (entry) {
if (entry.cid.equals(ref.Hash)) {
delete entry.change
} else {
entry.change = CHANGE_TYPE.EDIT
}
allEntries.set(ref.Name, entry)
} else {
const stat = await this.#fs.stat(ref.Hash)
allEntries.set(ref.Name, refToEntry(ref, fullPath, stat, CHANGE_TYPE.NEW))
}
}
return Array.from(allEntries.values())
}
/**
* Adds a file to the change filesystem.
* @param {string} path - The path where to add the file.
* @param {Uint8Array} content - The file content as a Uint8Array.
* @returns {Promise<void>} Resolves when the file is staged.
*/
async add(path, content, { forceAvatar = false } = {}) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
assert(!path.startsWith('/.avatar.') || forceAvatar, `${path} is a reserved filename`)
// if the folder exist under changeRoot, we need to ensure it's in the local blockstore
const split = path.split('/').filter(Boolean)
split.pop() // remove file name
const revSplit = split.reverse()
const tmpPath = []
while (revSplit.length > 0) {
tmpPath.push(revSplit.pop())
try { // ls will ensure the folder is in the local blockstore, if it's under changeRoot
await this.ls(tmpPath.join('/'))
} catch (_e) { /* Ignore errors, folder may not exist */ }
}
// Add file to changeRoot
await this.#setChangeRoot(await addFile(this.#fs, this.#changeRoot, path, content))
}
/**
* Sets the generated site artifacts for the website.
* @param {object} assets - The generated assets to store.
* @param {Uint8Array | null} assets.ensAvatar
* @param {Uint8Array | null} assets.ensFavicon
* @param {Uint8Array | null} assets.foamAvatar
* @param {Uint8Array | null} assets.foamFaviconLight
* @param {Uint8Array | null} assets.foamFaviconDark
* @returns {Promise<void>} Resolves when the artifacts are set.
*/
async setArtifacts({
ensAvatar,
ensFavicon,
foamAvatar,
foamFaviconLight,
foamFaviconDark,
}) {
await this.#isReady()
const base = `/${ARTIFACTS_FOLDER}`
const updates = [
{ name: 'ensAvatar.png', content: ensAvatar },
{ name: 'ensFavicon.png', content: ensFavicon },
{ name: 'foamAvatar.png', content: foamAvatar },
{ name: 'foamFavicon-light.png', content: foamFaviconLight },
{ name: 'foamFavicon-dark.png', content: foamFaviconDark },
]
for (const { name, content } of updates) {
const path = `${base}/${name}`
if (content) {
await this.add(path, content)
} else if (await this.#fileExists(path, true)) {
try {
await this.rm(path)
} catch (_e) { /* Ignore errors, file may not exist */ }
}
}
}
/**
* Gets the generated artifact paths for the website.
* @returns {Promise<{ ensAvatar: string | null, ensFavicon: string | null, foamAvatar: string | null, foamFaviconLight: string | null, foamFaviconDark: string | null }>} The artifact paths.
*/
async getArtifactPaths(noPrefix = false) {
await this.#isReady()
const artifactsRoot = await this.#fileExists(`/${ARTIFACTS_FOLDER}`, true)
if (!artifactsRoot) {
return {
ensAvatar: null,
ensFavicon: null,
foamAvatar: null,
foamFaviconLight: null,
foamFaviconDark: null,
}
}
const files = await this.ls(ARTIFACTS_FOLDER)
const resolve = (name) => files.find(f => f.name === name)?.path || null
const prefix = (path) => (!noPrefix && path ? `/${FILES_FOLDER}/${path}` : path)
return {
ensAvatar: prefix(resolve('ensAvatar.png')),
ensFavicon: prefix(resolve('ensFavicon.png')),
foamAvatar: prefix(resolve('foamAvatar.png')),
foamFaviconLight: prefix(resolve('foamFavicon-light.png')),
foamFaviconDark: prefix(resolve('foamFavicon-dark.png')),
}
}
/**
* Removes a file from the change filesystem or commited filesystem.
* @param {string} path - The path of the file to remove.
* @returns {Promise<void>} Resolves when the file is staged for deletion or removed.
*/
async rm(path) {
await this.#isReady()
// Remove from changeRoot
await this.#setChangeRoot(await rm(this.#fs, this.#changeRoot, path, { recursive: false }))
}
/**
* Creates a directory in the change filesystem.
* @param {string} path - The path of the directory to create.
* @returns {Promise<void>} Resolves when the directory is created.
*/
async mkdir(path) {
await this.#isReady()
// filter removes ""
const split = path.split('/').filter(Boolean)
if (await this.#fileExists(split.join('/'), true)) {
throw new Error(`Directory or file already exists: ${path}`)
}
let changePointer = await this.#fs.addDirectory()
do {
const name = split.pop()
const reminderPath = split.join('/')
const parentCid = await this.#fileExists(reminderPath, true)
if (parentCid) {
changePointer = await this.#fs.cp(changePointer, parentCid, name, { force: true })
} else {
throw new Error(`Parent directory ${reminderPath} does not exist`)
}
} while (split.length > 0)
await this.#setChangeRoot(changePointer)
}
/**
* Restores a file from the change filesystem to its commited state.
* @param {string} path - The path of the file to restore.
*/
async restore(path) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
// First check if file exists in root
const fileCid = await this.#fileExists(path)
if (!fileCid) {
// If file doesn't exist in root, just remove it from changeRoot
await this.#setChangeRoot(await rm(this.#fs, this.#changeRoot, path))
} else {
// Copy the file from root to changeRoot
const newChangeRoot = await cp(this.#fs, fileCid, this.#changeRoot, path, { force: true })
await this.#setChangeRoot(newChangeRoot)
}
}
/**
* Reads the content of a file from the change filesystem.
* @param {string} path - The path of the file to read.
* @returns {Promise<Uint8Array>} The file content as a Uint8Array.
*/
async cat(path) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
const fileCid = await this.#fileExists(path, true)
assert(fileCid, `File not found: ${path}`)
await this.#ensureContent(fileCid)
return concat(await all(this.#fs.cat(this.#changeRoot, { path })))
}
async hasChanges() {
await this.#isReady()
return !this.#changeRoot.equals(this.#root)
}
async #getUnchangedRoots(newCid, oldCid) {
if (newCid.equals(oldCid)) {
return new CidSet([newCid])
}
if (newCid.code === dagPb.code) {
const getNode = async (cid) => {
await this.#ensureContent(cid)
return dagPb.decode(await this.#blockstore.get(cid))
}
const [newNode, oldNode] = await Promise.all([ getNode(newCid), getNode(oldCid) ])
let cids = new CidSet()
for (const link of newNode.Links) {
const oldLink = oldNode.Links.find(l => l.Name === link.Name)
if (oldLink) {
const childCids = await this.#getUnchangedRoots(link.Hash, oldLink.Hash)
cids = new CidSet([...cids, ...childCids])
}
}
return cids
}
return new CidSet()
}
/**
* Stages all changes and returns a new CID of the updated filesystem root.
* @returns {Promise<{ cid: CID, unchangedCids: CidSet }>} The CID of the new root after staging changes.
*/
async stage() {
await this.#isReady()
// The changeRoot already contains all the staged changes
const unchangedCids = await this.#getUnchangedRoots(this.#changeRoot, this.#root)
return { cid: this.#changeRoot, unchangedCids }
}
/**
* Finalizes a commit.
* Clears out all edits and updates the repo state.
* @param {string} cid - The CID of the new repo root.
*/
async finalizeCommit(cid) {
await this.#isReady()
this.#root = cid
await this.#setChangeRoot(cid)
}
/**
* Clears all staged changes.
*/
async clearChanges() {
await this.#isReady()
await this.#setChangeRoot(this.#root)
}
}