adds ci/cd
This commit is contained in:
1 parent
0f7bb4b6eb
commit
44ca534cfa
13 files changed
+2040
-185
No files matched your search
+336
-6
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user