Refactored to use a build script
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
content.js
|
||||
35
bin/build
Executable file
35
bin/build
Executable file
@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
set +e
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
|
||||
cd "$DIR/.."
|
||||
|
||||
# Build script to combine all source files into a single content.js
|
||||
|
||||
# Create the output file and start the IIFE
|
||||
echo '(function() {' >content.js
|
||||
echo '' >>content.js
|
||||
|
||||
# Add each external library first
|
||||
for file in lib/*; do
|
||||
cat "$file" >>content.js
|
||||
echo "// === $file ===" >>content.js
|
||||
echo '' >>content.js
|
||||
echo '' >>content.js
|
||||
done
|
||||
|
||||
# Add each source file in order
|
||||
for file in src/*; do
|
||||
echo "// === $file ===" >>content.js
|
||||
cat "$file" >>content.js
|
||||
echo '' >>content.js
|
||||
echo '' >>content.js
|
||||
done
|
||||
|
||||
echo 'main();' >>content.js
|
||||
|
||||
# Close the IIFE
|
||||
echo '})();' >>content.js
|
||||
|
||||
echo "Built content.js from source files"
|
||||
File diff suppressed because one or more lines are too long
17
src/detector.js
Normal file
17
src/detector.js
Normal file
@ -0,0 +1,17 @@
|
||||
const ProjectDetector = {
|
||||
isProjectChat() {
|
||||
const projectLink = document.querySelector('a[href*="/project/"]');
|
||||
if (!projectLink) {
|
||||
console.log('Not in a project chat - no project link found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check: look for the forward slash separator in the project breadcrumb
|
||||
const breadcrumbWithSlash = projectLink.querySelector('span.opacity-50');
|
||||
if (!breadcrumbWithSlash || !breadcrumbWithSlash.textContent.includes('/')) {
|
||||
console.log('Not in a project chat - no forward slash separator found');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};// No exports needed - will be in same scope after build
|
||||
113
src/exporter.js
Normal file
113
src/exporter.js
Normal file
@ -0,0 +1,113 @@
|
||||
const ArtifactExporter = {
|
||||
|
||||
parseFileMap(fileMap) {
|
||||
const lines = fileMap.split('\n').filter(line => line.trim());
|
||||
const pathMapping = {};
|
||||
let currentPath = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Check if this is a directory (ends with /)
|
||||
if (trimmedLine.endsWith('/')) {
|
||||
currentPath = trimmedLine;
|
||||
} else if (trimmedLine && !trimmedLine.startsWith('/')) {
|
||||
if (line.startsWith(' ')) {
|
||||
// File is indented, so it's in the current directory
|
||||
pathMapping[trimmedLine] = currentPath + trimmedLine;
|
||||
} else {
|
||||
// File is at root level
|
||||
pathMapping[trimmedLine] = trimmedLine;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pathMapping;
|
||||
},
|
||||
|
||||
createFinalMapping(artifactCollection, pathMapping) {
|
||||
const finalMap = {};
|
||||
Object.entries(artifactCollection).forEach(([filename, content]) => {
|
||||
const fullPath = pathMapping[filename] || filename; // Use filename as-is if not in mapping
|
||||
finalMap[fullPath] = content;
|
||||
});
|
||||
return finalMap;
|
||||
},
|
||||
|
||||
generateZipFilename() {
|
||||
const now = new Date();
|
||||
const timestamp = now.getFullYear() + '-' +
|
||||
String(now.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(now.getDate()).padStart(2, '0');
|
||||
return `${timestamp}_claude-artifacts.zip`;
|
||||
},
|
||||
|
||||
addFilesToZip(zip, finalMap) {
|
||||
Object.entries(finalMap).forEach(([fullPath, content]) => {
|
||||
// Handle directory structure
|
||||
const pathParts = fullPath.split('/');
|
||||
|
||||
if (pathParts.length > 1) {
|
||||
// File is in a directory
|
||||
const filename = pathParts.pop();
|
||||
const folderPath = pathParts.join('/');
|
||||
const folder = zip.folder(folderPath);
|
||||
folder.file(filename, content);
|
||||
} else {
|
||||
// File is at root level
|
||||
zip.file(fullPath, content);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
downloadZip(content, filename) {
|
||||
const url = URL.createObjectURL(content);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`✓ Downloaded: ${filename}`);
|
||||
},
|
||||
|
||||
exportArtifacts(artifactCollection, fileMap) {
|
||||
// Validation
|
||||
if (!artifactCollection || Object.keys(artifactCollection).length === 0) {
|
||||
console.warn("No non-filemap artifacts were found. Ask the LLM to copy them from the project to this chat first.");
|
||||
return;
|
||||
}
|
||||
if (!fileMap) {
|
||||
console.warn("No filemap was found. Ask the LLM to generate one. See the README for a sample prompt.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the file map into paths
|
||||
const pathMapping = this.parseFileMap(fileMap);
|
||||
|
||||
// Create final map with full paths and contents
|
||||
const finalMap = this.createFinalMapping(artifactCollection, pathMapping);
|
||||
|
||||
// Create zip file
|
||||
const zip = new JSZip();
|
||||
|
||||
// Add each file to the zip
|
||||
this.addFilesToZip(zip, finalMap);
|
||||
|
||||
// Generate filename
|
||||
const zipFilename = this.generateZipFilename();
|
||||
|
||||
// Generate and download the zip
|
||||
zip.generateAsync({type: "blob"})
|
||||
.then((content) => {
|
||||
this.downloadZip(content, zipFilename);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to generate zip:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// No exports needed - will be in same scope after build
|
||||
15
src/main.js
Normal file
15
src/main.js
Normal file
@ -0,0 +1,15 @@
|
||||
async function main() {
|
||||
console.log('Running artifact collector');
|
||||
|
||||
if (!ProjectDetector.isProjectChat()) {
|
||||
console.log('Not in a project chat, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ArtifactScraper.collectArtifacts();
|
||||
ArtifactExporter.exportArtifacts(result.artifacts, result.fileMap);
|
||||
} catch (error) {
|
||||
console.error('Error during artifact collection and export:', error);
|
||||
}
|
||||
}
|
||||
202
src/scraper.js
Normal file
202
src/scraper.js
Normal file
@ -0,0 +1,202 @@
|
||||
const ArtifactScraper = {
|
||||
|
||||
openArtifactList() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Find the global artifact menu button (has the hamburger/list icon)
|
||||
const artifactMenuButton = document.querySelector('button[aria-haspopup="menu"] svg[viewBox="0 0 256 256"] path[d*="M80,64a8,8,0,0,1,8-8H216"]');
|
||||
const menuButton = artifactMenuButton?.closest('button');
|
||||
|
||||
if (!menuButton) {
|
||||
reject(new Error('Global artifact menu button not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
let hasResolved = false;
|
||||
|
||||
// Listen for menu opening
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
const addedNodes = Array.from(mutation.addedNodes);
|
||||
const menuAdded = addedNodes.some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.querySelector('li[role="none"] div[role="menuitem"]') ||
|
||||
node.getAttribute('role') === 'menu' ||
|
||||
node.querySelector('[role="menu"]'))
|
||||
);
|
||||
|
||||
if (menuAdded) {
|
||||
console.log('Artifact list menu has been opened');
|
||||
observer.disconnect();
|
||||
if (!hasResolved) {
|
||||
hasResolved = true;
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Focus and send Enter key event to open menu. Click doesn't actually work,
|
||||
// likely due to react pseudoevents.
|
||||
menuButton.focus();
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
menuButton.dispatchEvent(enterEvent);
|
||||
|
||||
// Fallback timeout so we don't accidentally listen forever.
|
||||
setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
observer.disconnect();
|
||||
hasResolved = true;
|
||||
reject(new Error('Failed to open artifact list menu - timeout'));
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
|
||||
getArtifactListItems() {
|
||||
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
|
||||
const artifactHash = {};
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
console.log('No artifact list items found');
|
||||
return artifactHash;
|
||||
}
|
||||
|
||||
menuItems.forEach((item, index) => {
|
||||
const filenameDiv = item.querySelector('div.line-clamp-2');
|
||||
if (filenameDiv) {
|
||||
const filename = filenameDiv.textContent.trim();
|
||||
// Check for aria-selected attribute or other indicators of selection
|
||||
const isSelected = item.getAttribute('aria-selected') === 'true' ||
|
||||
item.classList.contains('bg-accent-secondary-100') ||
|
||||
item.classList.contains('border-accent-secondary-100');
|
||||
|
||||
artifactHash[filename] = isSelected;
|
||||
console.log(`Found artifact: ${filename} (${isSelected ? 'selected' : 'unselected'})`);
|
||||
}
|
||||
});
|
||||
|
||||
return artifactHash;
|
||||
},
|
||||
|
||||
selectArtifactListItem(filename) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const menuItems = document.querySelectorAll('li[role="none"] div[role="menuitem"]');
|
||||
let targetItem = null;
|
||||
|
||||
// Find the matching artifact by filename
|
||||
menuItems.forEach(item => {
|
||||
const filenameDiv = item.querySelector('div.line-clamp-2');
|
||||
if (filenameDiv && filenameDiv.textContent.trim() === filename) {
|
||||
targetItem = item;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetItem) {
|
||||
reject(new Error(`Could not find artifact item for: ${filename}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Selecting artifact: ${filename}`);
|
||||
|
||||
// Focus and send Enter key event to select the item
|
||||
// Click may or may not work here, but keydown was needed elsewhere, so we're
|
||||
// sticking to it here.
|
||||
targetItem.focus();
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
targetItem.dispatchEvent(enterEvent);
|
||||
|
||||
// Wait for artifact to load
|
||||
setTimeout(() => {
|
||||
resolve(true);
|
||||
}, 1000);
|
||||
});
|
||||
},
|
||||
|
||||
getArtifact(filename, isCurrentlySelected) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (!isCurrentlySelected) {
|
||||
const menuOpen = document.querySelector('li[role="none"] div[role="menuitem"]');
|
||||
if (!menuOpen) {
|
||||
try {
|
||||
await this.openArtifactList();
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to open menu to select: ${filename} - ${error.message}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.selectArtifactListItem(filename);
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to select artifact: ${filename} - ${error.message}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const codeBlock = document.querySelector('div.right-0 code');
|
||||
if (!codeBlock) {
|
||||
reject(new Error(`No code block found for artifact: ${filename}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(codeBlock.textContent || codeBlock.innerText || '');
|
||||
});
|
||||
},
|
||||
|
||||
async collectArtifacts() {
|
||||
const artifactCollection = {};
|
||||
// There's no path information in a Claude artifact, just filenames.
|
||||
// However, the LLM itself is smart enough to know where the files should go.
|
||||
// So our strategy is to make it save those paths to a files.txt artifact and store
|
||||
// that separately for use in making a final map of artifacts to files for our zip export.
|
||||
let fileMap = null;
|
||||
|
||||
await this.openArtifactList();
|
||||
const artifactList = this.getArtifactListItems();
|
||||
|
||||
if (Object.keys(artifactList).length === 0) {
|
||||
console.log('No artifacts found in list');
|
||||
return { artifacts: artifactCollection, fileMap: fileMap };
|
||||
}
|
||||
|
||||
console.log(`Found ${Object.keys(artifactList).length} artifacts to collect`);
|
||||
|
||||
for (const [filename, isSelected] of Object.entries(artifactList)) {
|
||||
try {
|
||||
const content = await this.getArtifact(filename, isSelected);
|
||||
|
||||
if (filename === 'files.txt') {
|
||||
// This is our special llm-generated mapping that we'll need to finish our export.
|
||||
fileMap = content;
|
||||
console.log(`✓ Found files.txt - storing in fileMap`);
|
||||
} else {
|
||||
artifactCollection[filename] = content;
|
||||
console.log(`✓ Collected: ${filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`✗ Failed to collect: ${filename} - ${error.message}`);
|
||||
}
|
||||
|
||||
// Small delay between artifacts
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return { artifacts: artifactCollection, fileMap: fileMap };
|
||||
}
|
||||
};
|
||||
|
||||
// No exports needed - will be in same scope after build
|
||||
Reference in New Issue
Block a user