adds ci/cd

This commit is contained in:
rehlert committed 2026-07-22 21:28:50 +02:00
1 parent 0f7bb4b6eb
commit 44ca534cfa
13 files changed
+2040 -185

No files matched your search

+336 -6
View File
@@ -12,11 +12,33 @@ export type ZipAttachment = {
data: Uint8Array;
};
export type ProcessedZipArchive = {
export type SubDocument = {
id: string;
name: string;
attachments: ZipAttachment[];
};
export type ProcessedZipArchive = {
name: string;
attachments: ZipAttachment[];
subDocuments: SubDocument[];
};
export type ExportMode = 'single' | 'separate';
export type ExportUnit = {
name: string;
bytes: Uint8Array;
};
/**
* Drag source/target descriptors shared between the archive editor and the
* sub-document editor so HTML5 `dataTransfer` payloads stay consistent.
*/
export type AttachmentLocation =
| { kind: 'loose'; index: number }
| { kind: 'subDocument'; id: string; index: number };
const IMAGE_EXTENSIONS = new Set([
'.avif',
'.bmp',
@@ -172,7 +194,8 @@ const extractZipEntries = async (archiveBytes: Uint8Array): Promise<ProcessedZip
if (orderedAttachments.length > 0) {
archives.push({
name: archiveName,
attachments: orderedAttachments
attachments: orderedAttachments,
subDocuments: []
});
}
@@ -283,16 +306,323 @@ const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAtt
}
};
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
if (archive.attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
export const mergeAttachments = async (attachments: ZipAttachment[]): Promise<Uint8Array> => {
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty attachment list.');
}
const mergedPdf = await PDFDocument.create();
for (const attachment of archive.attachments) {
for (const attachment of attachments) {
await mergeAttachmentIntoPdf(mergedPdf, attachment);
}
return mergedPdf.save();
};
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
if (archive.attachments.length === 0 && archive.subDocuments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return mergeAttachments(flattenArchiveForMerge(archive));
};
/**
* Flatten the archive into a single ordered attachment list, preserving
* sub-document order first and appending loose attachments at the end.
*/
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
const ordered: ZipAttachment[] = [];
for (const subDocument of archive.subDocuments) {
ordered.push(...subDocument.attachments);
}
ordered.push(...archive.attachments);
return ordered;
};
/**
* Create a new sub-document with a stable id from a list of attachments.
*/
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
id: crypto.randomUUID(),
name,
attachments: attachments.map((attachment) => ({ ...attachment }))
});
/**
* Reorder sub-documents inside an archive. Matches the semantics of a single
* `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move.
*/
export const moveSubDocument = (
archive: ProcessedZipArchive,
fromIndex: number,
toIndex: number
): ProcessedZipArchive => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= archive.subDocuments.length ||
toIndex >= archive.subDocuments.length
) {
return archive;
}
const nextSubDocuments = [...archive.subDocuments];
const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1);
nextSubDocuments.splice(toIndex, 0, movedSubDocument);
return { ...archive, subDocuments: nextSubDocuments };
};
const removeAt = <T>(list: T[], index: number): T | null => {
if (index < 0 || index >= list.length) {
return null;
}
const [item] = list.splice(index, 1);
return item ?? null;
};
const insertAt = <T>(list: T[], index: number, item: T) => {
const clampedIndex = Math.max(0, Math.min(index, list.length));
list.splice(clampedIndex, 0, item);
};
/**
* Reorder an attachment within its current location (loose list or a single
* sub-document's attachment list). Used by the per-list drag handlers.
*/
export const moveAttachmentInPlace = (
archive: ProcessedZipArchive,
location: AttachmentLocation,
toIndex: number
): ProcessedZipArchive => {
if (location.kind === 'loose') {
const nextAttachments = [...archive.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return archive;
}
insertAt(nextAttachments, toIndex, moved);
return { ...archive, attachments: nextAttachments };
}
let changed = false;
const nextSubDocuments = archive.subDocuments.map((subDocument) => {
if (subDocument.id !== location.id) {
return subDocument;
}
const nextAttachments = [...subDocument.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return subDocument;
}
insertAt(nextAttachments, toIndex, moved);
changed = true;
return { ...subDocument, attachments: nextAttachments };
});
return changed ? { ...archive, subDocuments: nextSubDocuments } : archive;
};
/**
* Move an attachment from a source location to a target location. Either side
* may be the loose list or any sub-document. The attachment moves (not copies),
* so the total attachment count is invariant. When source and target point to
* the same list, this degenerates to an in-place reorder (explicitly delegated
* to `moveAttachmentInPlace` so the indices are normalized correctly).
*/
export const moveAttachmentIntoSubDocument = (
archive: ProcessedZipArchive,
source: AttachmentLocation,
target: AttachmentLocation
): ProcessedZipArchive => {
const sameList =
source.kind === 'loose' && target.kind === 'loose'
? true
: source.kind === 'subDocument' && target.kind === 'subDocument'
? source.id === target.id
: false;
if (sameList) {
return moveAttachmentInPlace(archive, source, target.index);
}
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const looseAttachments = [...archive.attachments];
const removeFromList = (location: AttachmentLocation): ZipAttachment | null => {
if (location.kind === 'loose') {
return removeAt(looseAttachments, location.index);
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
return target_ ? removeAt(target_.attachments, location.index) : null;
};
const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => {
if (location.kind === 'loose') {
insertAt(looseAttachments, location.index, attachment);
return;
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
if (!target_) {
// fall back to loose list end if the referenced sub-document vanished
looseAttachments.push(attachment);
return;
}
insertAt(target_.attachments, location.index, attachment);
};
const movedAttachment = removeFromList(source);
if (!movedAttachment) {
return archive;
}
insertIntoList(movedAttachment, target);
return {
...archive,
attachments: looseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove a single attachment from a sub-document and return it to the end of
* the loose list. Useful as a targeted "move back to loose" action.
*/
export const removeAttachmentFromSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string,
attachmentIndex: number
): ProcessedZipArchive => {
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const nextLooseAttachments = [...archive.attachments];
const target_ = nextSubDocuments.find((item) => item.id === subDocumentId);
if (!target_) {
return archive;
}
const moved = removeAt(target_.attachments, attachmentIndex);
if (!moved) {
return archive;
}
nextLooseAttachments.push(moved);
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove an entire sub-document and return its attachments to the end of the
* loose list. The sub-document ids of the remaining sub-documents are
* preserved.
*/
export const dissolveSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string
): ProcessedZipArchive => {
const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId);
if (!dissolved) {
return archive;
}
return {
...archive,
attachments: [...archive.attachments, ...dissolved.attachments],
subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId)
};
};
const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
/**
* Build one or more PDF export units from the archive.
*
* - `mode: 'single'` mirrors today's behaviour: one merged PDF covering
* sub-documents first, then loose attachments, named after the archive.
* - `mode: 'separate'` produces one PDF per sub-document (named
* `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any
* loose attachments (named after the archive). Empty groups are skipped.
*/
export const buildArchiveExport = async (
archive: ProcessedZipArchive,
mode: ExportMode
): Promise<ExportUnit[]> => {
if (mode === 'single') {
const attachments = flattenArchiveForMerge(archive);
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return [
{
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(attachments)
}
];
}
const separateUnits: ExportUnit[] = [];
for (const subDocument of archive.subDocuments) {
if (subDocument.attachments.length === 0) {
continue;
}
separateUnits.push({
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
});
}
if (archive.attachments.length > 0) {
separateUnits.push({
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(archive.attachments)
});
}
if (separateUnits.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return separateUnits;
};