UNPKG

async-neocities

Version:

A library and bin to deploy to neocities

187 lines (157 loc) 6.4 kB
/** * @import { SiteFileList, FileUpload } from './neocities.js' * @import afw from 'async-folder-walker' */ import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import { pipeline } from 'node:stream/promises' import { keyBy } from './key-by.js' import { supportedFiletypes } from './supported-filetypes.js' /** * @typedef {{ * filesToUpload: FileUpload[] * filesToDelete: string[] * filesSkipped: string[] * protectedFiles: string[] * unsupportedFiles: string[] * }} AsyncNeocitiesDiff */ /** * neocitiesLocalDiff returns an array of files to delete and update and some useful stats. * @param {object} params * @param {SiteFileList['files']} params.neocitiesFiles Array of files returned from the neocities list api. * @param {afw.FWStats[]} params.localListing Array of files returned by a full data async-folder-walker run. * @param {(path: string) => boolean} [params.protectedFileFilter] * @param {boolean} [params.includeUnsupportedFiles=false] Set to true to bypass file type restrictions when a neocities supporter account is used. * @return {Promise<AsyncNeocitiesDiff>} */ export async function neocitiesLocalDiff ({ neocitiesFiles, localListing, protectedFileFilter = (_path) => false, includeUnsupportedFiles = false }) { const neoCitiesFiltered = neocitiesFiles.filter(f => !f.is_directory) const ncIndex = keyBy(neoCitiesFiltered, 'path') const ncFiles = new Set(neoCitiesFiltered.map(f => f.path)) // shape const localListingFiltered = localListing.filter(f => !f.stat.isDirectory()) // files only localListingFiltered.forEach(v => { v.relname = forceUnixRelname(v.relname) }) const localIndex = keyBy(localListingFiltered, 'relname') const localFiles = new Set(localListingFiltered.map(f => f.relname)) // shape const unsupportedFilesFiltered = localListingFiltered.filter(f => !supportedFiletypes.has(path.extname(f.basename).toLowerCase()) ) const unsupportedFilesSet = new Set(unsupportedFilesFiltered.map(f => f.relname)) /** @type {Set<string>} */ const protectedSet = new Set() ncFiles.forEach(ncFile => { if (protectedFileFilter(ncFile)) protectedSet.add(ncFile) }) const localFilesWorkingSet = includeUnsupportedFiles ? localFiles // All local files, regardless of file support (neocities supporter only) : difference(localFiles, unsupportedFilesSet) // Only upload supported filetypes const filesToAdd = difference(localFilesWorkingSet, ncFiles) const filesToDeleteSet = difference(difference(ncFiles, localFilesWorkingSet), protectedSet) const maybeUpdate = intersection(localFilesWorkingSet, ncFiles) /** @type {Set<string>} */ const skipped = new Set() for (const p of maybeUpdate) { const local = localIndex[p] const remote = ncIndex[p] if (!local) throw new Error(`Missing local file stats for ${p}`) if (!remote) throw new Error(`Missing remote file stats for ${p}`) if (local.stat.size !== remote.size) { filesToAdd.add(p); continue } const localSha1 = await sha1FromPath(local.filepath) if (localSha1 !== remote.sha1_hash) { filesToAdd.add(p); continue } skipped.add(p) } const filesToUpload = Array.from(filesToAdd).map(p => { const localFile = localIndex[p] if (!localFile) throw new Error(`Unable to lookup localFile for upload ${p}`) return /** @type {FileUpload} */({ name: forceUnixRelname(localFile.relname), path: localFile.filepath }) }) const filesToDelete = Array.from(filesToDeleteSet).map(p => { const neocitiesFile = ncIndex[p] if (!neocitiesFile) throw new Error(`Error looking up neocities file ${p}`) return neocitiesFile.path }) const filesSkipped = Array.from(skipped).map(p => { const localSkipFile = localIndex[p] if (!localSkipFile) throw new Error(`Error looking up localSkipFile ${p}`) return localSkipFile.relname }) const protectedFiles = Array.from(protectedSet).map(p => { const protectedFile = ncIndex[p] if (!protectedFile) throw new Error(`Error looking up protected file ${p}`) return protectedFile.path }) const unsupportedFiles = Array.from(unsupportedFilesSet).map(p => { const unsupportedFile = localIndex[p] if (!unsupportedFile) throw new Error(`Error looking up unsupportedFile file ${p}`) return unsupportedFile.relname }) return { filesToUpload, filesToDelete, filesSkipped, protectedFiles, unsupportedFiles } } /** * sha1FromPath returns a sha1 hex from a path * @param {string} p string of the path of the file to hash * @return {Promise<string>} the hex representation of the sha1 */ async function sha1FromPath (p) { const rs = fs.createReadStream(p) const hash = crypto.createHash('sha1') await pipeline(rs, hash, { end: false }) return hash.digest('hex') } /** * Computes the difference between two sets (setA \ setB). * * @template T * @param {Set<T>} setA - The left-hand side set. * @param {Set<T>} setB - The right-hand side set. * @returns {Set<T>} - A new set containing the elements in setA that are not in setB. * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#Implementing_basic_set_operations|MDN: Implementing basic set operations} */ function difference (setA, setB) { const _difference = new Set(setA) for (const elem of setB) { _difference.delete(elem) } return _difference } /** * Computes the intersection of two sets (setA ∩ setB). * * @template T * @param {Set<T>} setA - The left-hand side set. * @param {Set<T>} setB - The right-hand side set. * @returns {Set<T>} - A new set containing the elements that are in both setA and setB. * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#Implementing_basic_set_operations|MDN: Implementing basic set operations} */ function intersection (setA, setB) { const _intersection = new Set() for (const elem of setB) { if (setA.has(elem)) { _intersection.add(elem) } } return _intersection } /** * forceUnixRelname forces a OS dependent path to a unix style path. * @param {String} relname String path to convert to unix style. * @return {String} The unix variant of the path */ function forceUnixRelname (relname) { return relname.split(path.sep).join('/') }