3c51cf6f81
- Hand-rolled ZIP writer/reader (node:zlib only) with CRC verification, UTF-8 names, store/deflate, path-traversal validation; verified interoperable with system unzip - .sfgz guide archives: export, copy-import (fresh ids, remapped substeps), linked-import with explicit write-back save - Advisory .lock-sfgz sidecar locks with stale detection and force-steal - Snapshot backups with pruning and undoable restore - 7 more workflow tests (19 total) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
6.1 KiB
JavaScript
174 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { zipSync, unzipSync } = require('./zip');
|
|
const { newId, nowIso, atomicWriteFileSync, writeJsonSync, deepClone } = require('./util');
|
|
const { normalizeGuide, normalizeStep, validateGuide, validateStep, SCHEMA_VERSION } = require('./schema');
|
|
const { acquireLock, releaseLock } = require('./locks');
|
|
|
|
const ARCHIVE_FORMAT = 'stepforge-guide-archive';
|
|
const APP_VERSION = require('../package.json').version;
|
|
|
|
/**
|
|
* Single-file share archive (.sfgz). Zip layout:
|
|
* manifest.json
|
|
* guide.json
|
|
* steps/<stepId>/step.json
|
|
* steps/<stepId>/<image files>
|
|
*/
|
|
|
|
function buildArchiveEntries(store, guideId) {
|
|
const guide = store.getGuide(guideId);
|
|
const steps = store.listSteps(guideId);
|
|
const entries = [];
|
|
|
|
const manifest = {
|
|
format: ARCHIVE_FORMAT,
|
|
formatVersion: 1,
|
|
schemaVersion: SCHEMA_VERSION,
|
|
appVersion: APP_VERSION,
|
|
guideId: guide.guideId,
|
|
title: guide.title,
|
|
exportedAt: nowIso(),
|
|
stepCount: guide.stepsOrder.length,
|
|
};
|
|
entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
|
|
|
|
const portableGuide = deepClone(guide);
|
|
portableGuide.linkedSource = null; // links are a property of the library, not the file
|
|
entries.push({ name: 'guide.json', data: JSON.stringify(portableGuide, null, 2) });
|
|
|
|
for (const stepId of guide.stepsOrder) {
|
|
const step = steps.get(stepId);
|
|
if (!step) continue;
|
|
entries.push({ name: `steps/${stepId}/step.json`, data: JSON.stringify(step, null, 2) });
|
|
const dir = store.stepDir(guideId, stepId);
|
|
for (const file of fs.readdirSync(dir)) {
|
|
if (file === 'step.json') continue;
|
|
entries.push({ name: `steps/${stepId}/${file}`, data: fs.readFileSync(path.join(dir, file)), store: /\.(png|jpg|jpeg|gif|webp)$/i.test(file) });
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Export a guide to a .sfgz file. Returns the manifest written. */
|
|
function exportGuideArchive(store, guideId, destFile) {
|
|
const entries = buildArchiveEntries(store, guideId);
|
|
atomicWriteFileSync(destFile, zipSync(entries));
|
|
return JSON.parse(entries[0].data);
|
|
}
|
|
|
|
function readArchive(file) {
|
|
const entries = unzipSync(fs.readFileSync(file));
|
|
const byName = new Map(entries.map((e) => [e.name, e.data]));
|
|
if (!byName.has('manifest.json') || !byName.has('guide.json')) {
|
|
throw new Error('not a StepForge guide archive (missing manifest)');
|
|
}
|
|
const manifest = JSON.parse(byName.get('manifest.json').toString('utf8'));
|
|
if (manifest.format !== ARCHIVE_FORMAT) throw new Error(`unsupported archive format: ${manifest.format}`);
|
|
if (manifest.schemaVersion > SCHEMA_VERSION) {
|
|
throw new Error(`archive uses newer schema (${manifest.schemaVersion}) than this app supports`);
|
|
}
|
|
const guide = normalizeGuide(JSON.parse(byName.get('guide.json').toString('utf8')));
|
|
validateGuide(guide);
|
|
return { manifest, guide, entries };
|
|
}
|
|
|
|
/**
|
|
* Import a .sfgz into the library.
|
|
* mode 'copy' — fresh ids, fully independent local guide.
|
|
* mode 'linked' — keeps archive identity; edits autosave locally and write
|
|
* back to the file only on explicit save (saveLinkedGuide).
|
|
*/
|
|
function importGuideArchive(store, file, { mode = 'copy' } = {}) {
|
|
const { guide, entries } = readArchive(file);
|
|
|
|
const idMap = new Map();
|
|
const stepFiles = new Map(); // newStepId -> [{name, data}]
|
|
const stepJsons = new Map();
|
|
|
|
for (const { name, data } of entries) {
|
|
const m = /^steps\/([^/]+)\/(.+)$/.exec(name);
|
|
if (!m) continue;
|
|
const [, oldStepId, rest] = m;
|
|
if (!idMap.has(oldStepId)) {
|
|
idMap.set(oldStepId, mode === 'copy' ? newId('step') : oldStepId);
|
|
}
|
|
const stepId = idMap.get(oldStepId);
|
|
if (rest === 'step.json') stepJsons.set(stepId, { oldStepId, raw: JSON.parse(data.toString('utf8')) });
|
|
else {
|
|
if (!stepFiles.has(stepId)) stepFiles.set(stepId, []);
|
|
if (!/^[A-Za-z0-9._-]+$/.test(rest)) continue; // only flat, safe file names
|
|
stepFiles.get(stepId).push({ name: rest, data });
|
|
}
|
|
}
|
|
|
|
const newGuide = deepClone(guide);
|
|
if (mode === 'copy') {
|
|
newGuide.guideId = newId('guide');
|
|
newGuide.linkedSource = null;
|
|
} else {
|
|
if (store.guideExists(newGuide.guideId)) {
|
|
throw new Error('this shared guide is already in the library');
|
|
}
|
|
newGuide.linkedSource = {
|
|
path: path.resolve(file),
|
|
openedAt: nowIso(),
|
|
lastSavedAt: null,
|
|
};
|
|
}
|
|
newGuide.stepsOrder = guide.stepsOrder.map((id) => idMap.get(id)).filter(Boolean);
|
|
|
|
return finalizeImport(store, newGuide, idMap, stepJsons, stepFiles);
|
|
}
|
|
|
|
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
|
validateGuide(newGuide);
|
|
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
|
|
|
|
for (const [stepId, { raw }] of stepJsons) {
|
|
const step = normalizeStep({ ...raw, stepId });
|
|
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
|
validateStep(step);
|
|
const dir = store.stepDir(newGuide.guideId, stepId);
|
|
writeJsonSync(path.join(dir, 'step.json'), step);
|
|
for (const { name, data } of stepFiles.get(stepId) || []) {
|
|
atomicWriteFileSync(path.join(dir, name), data);
|
|
}
|
|
}
|
|
return store.getGuide(newGuide.guideId);
|
|
}
|
|
|
|
/**
|
|
* Write a linked guide back to its shared archive (explicit Ctrl+S save).
|
|
* Takes the advisory lock for the duration of the write.
|
|
*/
|
|
function saveLinkedGuide(store, guideId, { force = false } = {}) {
|
|
const guide = store.getGuide(guideId);
|
|
if (!guide.linkedSource || !guide.linkedSource.path) {
|
|
throw new Error('guide is not linked to a shared archive');
|
|
}
|
|
const target = guide.linkedSource.path;
|
|
const result = acquireLock(target, { force });
|
|
if (!result.acquired) {
|
|
return { saved: false, conflict: result.conflict };
|
|
}
|
|
try {
|
|
exportGuideArchive(store, guideId, target);
|
|
guide.linkedSource.lastSavedAt = nowIso();
|
|
store.saveGuide(guide, { touch: false });
|
|
return { saved: true, path: target };
|
|
} finally {
|
|
releaseLock(target);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ARCHIVE_FORMAT,
|
|
exportGuideArchive,
|
|
readArchive,
|
|
importGuideArchive,
|
|
saveLinkedGuide,
|
|
};
|