summaryrefslogtreecommitdiff
path: root/src/fossil.attach.js
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
committercashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
commit4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch)
treec296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.attach.js
downloadrelic-master.tar.gz
initial commitHEADmaster
Diffstat (limited to 'src/fossil.attach.js')
-rw-r--r--src/fossil.attach.js663
1 files changed, 663 insertions, 0 deletions
diff --git a/src/fossil.attach.js b/src/fossil.attach.js
new file mode 100644
index 0000000..405a0cc
--- /dev/null
+++ b/src/fossil.attach.js
@@ -0,0 +1,663 @@
+"use strict";
+/**
+ Utility for interactive file attachment. Supports attachment
+ selection from a file dialog, from the clipboard, or drag/drop.
+
+ Requires that window.fossil has already been set up.
+ Depends on fossil.dom.
+*/
+(function(namespace){
+ "use strict";
+ const F = namespace, D = F.dom;
+
+ let idCounter = 0;
+ /**
+ Implements a multi-file selector widget. Intended to be plugged
+ in to places in Fossil's UI where attachments can be assigned to
+ an artifact.
+ */
+ class Attacher {
+ /* Options. */
+ #opt;
+ /* List of objects representing each row. */
+ #rows = [];
+ /* DOM elements */
+ #e = Object.create(null);
+ /* Proxy for various events this object fires. */
+ #events = new EventTarget();
+
+ /**
+ Options:
+
+ opt.container: Optional DOM element to append the resulting
+ widget to. If not set, the client can get access to the widget
+ element using this.body.
+
+ opt.addButtonLabel: optional label for the "add attachment"
+ button, defaulting to something generic.
+
+ opt.limit: optional max number of attachments to allow. This
+ defaults to "some sensible value".
+
+ opt.startWith[=0]: if >0 then that many file selection widgets
+ are automatically activated, as if the user had tapped the Add
+ button that many times.
+
+ opt.description[=true]: if true then show the file description
+ field, otherwise elide it.
+
+ opt.reverse[=false]: reverses the flow of the widget such that
+ the Add button stays on the top and rows are ordered
+ most-recently-added.
+
+ opt.controls = [array of DOM elements]. Optional DOM elements
+ to inject into the UI element which wraps the "Add" button.
+ See this.controlsElement.
+
+ opt.listener = function or object: {add: func, remove: func,
+ populate: func}: if these are functions they are registered as
+ listeners for 'entry-added', 'entry-removed', and/or
+ 'entry-populated' events, described below. opt.listener.all, if
+ set, is used as a fallback for any of 'add', 'remove', or
+ 'populate' which are not set. If opt.listener is a function
+ then it behaves as if listener={all: thatFunction}.
+
+ Events:
+
+ This class fires CustomEvents for certain changes:
+
+ 'entry-added' and 'entry-removed' trigger when an attachment
+ entry row is added/removed. Its event.detail is:
+
+ {attacher: this, row: object, type: 'same as event type'}.
+
+ 'entry-populated' is triggered when a visible entry gets
+ content attached to it, with the same detail structure as
+ described above.
+
+ The public structure of the row object passed to each is
+ currently TBD.
+ */
+ constructor(opt){
+ this.#opt = opt = F.nu({
+ addButtonLabel: false,
+ startWith: 0,
+ limit: 0,
+ dryRun: undefined,
+ description: true,
+ reverse: false
+ }, opt);
+ this.#e.body = D.addClass(D.div(), 'Attacher');
+ if( opt.reverse ) this.#e.body.classList.add('reverse');
+ const eBtnAdd = this.#e.btnAdd = D.addClass(
+ D.button(this.#opt.addButtonLabel || 'Add attachment',
+ ()=>this.#addRow()),
+ 'attach-add-button'
+ );
+ eBtnAdd.type = 'button';
+ opt.ownsAddButton = true;
+ this.#e.err = D.addClass(D.div(), 'error', 'hidden');
+ this.#e.body.append(this.#e.err);
+ this.#e.err.addEventListener('dblclick',()=>this.reportError());
+
+ const eControls = this.#e.controls =
+ D.addClass(D.div(), 'attach-controls');
+ eControls.append(eBtnAdd);
+ if( opt.container ){
+ opt.container.appendChild(this.#e.body);
+ }
+ this.#e.body.appendChild(eControls);
+ if( opt.listener ){
+ const doCb = (eventType, key)=>{
+ const f = (opt.listener instanceof Function)
+ ? opt.listener
+ : (opt.listener[key] || opt.listener.all);
+ if( f instanceof Function ){
+ this.addEventListener(eventType, f);
+ }
+ };
+ doCb('entry-added', 'add');
+ doCb('entry-removed', 'remove');
+ doCb('entry-populated', 'populate');
+ }
+ if( opt.dryRun ){
+ /* Add dry-run toggle for testing. */
+ const eLbl = D.label(false, "Dry-run?");
+ const eCb = D.checkbox(true);
+ eLbl.append(eCb);
+ eControls.append(eLbl);
+ eCb.checked = opt.dryRun = true;
+ eCb.addEventListener('change',()=>opt.dryRun=eCb.checked);
+ }
+ if( Array.isArray(opt.controls) ){
+ eControls.append(...opt.controls);
+ }
+ if( opt.startWith > 0 ){
+ for(let i = 0; i < opt.startWith; ++i ){
+ this.#addRow();
+ }
+ }else{
+ this.#updateControls();
+ }
+ }
+
+
+ get widget(){
+ return this.#e.body;
+ }
+
+ addEventListener(...args){
+ return this.#events.addEventListener(...args);
+ }
+
+ removeEventListener(...args){
+ return this.#events.removeEventListener(...args);
+ }
+
+ /** Returns true if any visible input widgets have content
+ selected. */
+ get isPopulated(){
+ for(let r of this.#rows){
+ if( r.file ) return true;
+ }
+ return false;
+ }
+
+ get isDryRun(){
+ return !!this.#opt.dryRun;
+ }
+ /**
+ Returns the DOM element (div.attach-controls) which wraps the
+ "Add" button. Clients may add buttons to it.
+ */
+ get controlsElement(){
+ return this.#e.controls;
+ }
+
+ /**
+ Reports an error by appending each argument to the error widget
+ and unhiding it. If passed no arugments, it clears and hides
+ the error widget.
+ */
+ reportError(...msg){
+ const e = this.#e.err;
+ D.clearElement(e);
+ if( msg.length ){
+ e.classList.remove('hidden');
+ e.append(...msg);
+ }else{
+ e.classList.add('hidden');
+ }
+ }
+
+ #removeRow(rowObj){
+ const er = rowObj.e.row;
+ if( er.parentNode ){
+ this.#rows = this.#rows.filter(v=>v!==rowObj);
+ this.#updateControls();
+ er.classList.add('animate-exit');
+ er.addEventListener('animationend', ()=>er.remove(), {once: true});
+ this.#events.dispatchEvent(
+ new CustomEvent('entry-removed',{
+ detail: F.nu({
+ type: 'entry-removed',
+ row: rowObj,
+ attacher: this
+ })
+ })
+ );
+ }
+ }
+
+ /**
+ Removes all attachments and clears the error state.
+ */
+ clear(){
+ for(const r of [...this.#rows/*clone because this updates #rows*/]){
+ this.#removeRow(r);
+ }
+ this.reportError();
+ }
+
+ /**
+ Hides or shows the Add button, as appropriate.
+ */
+ #updateControls(){
+ const b = this.#e.btnAdd;
+ if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){
+ b.classList.add('hidden');
+ D.disable(b);
+ //F.toast.warning("Attachment form limit reached.");
+ }else{
+ b.classList.remove('hidden');
+ D.enable(b);
+ if( this.#opt.ownsAddButton ){
+ this.#e.body.append(this.#e.controls/*move to the end*/);
+ }
+ }
+ }
+
+ /**
+ Returns the "Add" button widget, Passing control of it to the
+ caller so that they can place it in another location. This
+ object will still manage its enabled/disabled/hidden state but
+ will no longer move it when adding a row.
+ */
+ takeAddButton(){
+ if( this.#opt.ownsAddButton ){
+ this.#opt.ownsAddButton;
+ }
+ return this.#e.btnAdd;
+ }
+ /**
+ Sets rowObj.e.err up with an error message, or clears it if
+ passed only 1 argument.
+ */
+ #rowError(rowObj,...msg){
+ let e = rowObj.e.err;
+ if( e ){
+ D.clearElement(e);
+ }else{
+ if( !msg.length ) return;
+ e = rowObj.e.err = D.addClass(D.span(), 'error');
+ rowObj.e.info.append(e);
+ }
+ if( msg.length ){
+ e.append(...msg);
+ e.classList.remove('hidden');
+ }else{
+ e.classList.add('hidden');
+ }
+ }
+
+ #addRow(){
+ const id = ++idCounter;
+ const rowObj = F.nu({
+ id, file: null, mimeType: ''
+ });
+ const eRow = D.addClass(D.div(), 'attach-row');
+ const eDropzone = D.addClass(D.div(), 'attach-dropzone');
+ const eFile = D.addClass(
+ D.input('file'), 'attach-file-input', 'hidden'
+ );
+ const eInfo = D.addClass(D.span(), 'attach-row-info');
+ const eFilename = D.append(
+ D.addClass(D.span(), 'attach-filename'),
+ "Select/drop file or click the outer border and tap your "+
+ "platform's conventional <paste> keyboard shortcut."
+ );
+ const eSize = D.addClass(D.span(), 'attach-size');
+ eInfo.append(eFilename, eSize);
+ const eDesc = this.#opt.description
+ ? D.addClass(
+ D.attr(D.textarea(), 'placeholder',
+ 'Optional description...'),
+ 'attach-desc'
+ )
+ : undefined;
+ const eRemove = D.addClass(
+ D.button('X', (ev)=>{
+ ev.stopPropagation();
+ this.#removeRow(rowObj);
+ }),
+ 'attach-row-remove'
+ );
+ eRemove.setAttribute('title', 'Remove this attachment.');
+ eRemove.type = 'button';
+
+ D.append(eDropzone, eInfo, eFile, eRemove);
+ eDropzone.addEventListener('click', ()=>eFile.click());
+ eFile.addEventListener('change', (ev)=>{
+ if( ev.target.files.length ){
+ this.#injestBlob(rowObj, ev.target.files[0]);
+ }
+ });
+
+ eDropzone.addEventListener('dragover', (ev)=>{
+ ev.preventDefault();
+ eDropzone.classList.add('dragover');
+ });
+ eDropzone.addEventListener('dragleave', (ev)=>{
+ eDropzone.classList.remove('dragover');
+ });
+ const handleDrop = (ev, theRealRowObj)=>{
+ ev.preventDefault();
+ eDropzone.classList.remove('dragover');
+ if( ev.dataTransfer.files.length ){
+ const r = theRealRowObj || rowObj;
+ this.#injestBlob(r, ev.dataTransfer.files[0]);
+ }
+ };
+ /* Isn't working? eBtnAdd.addEventListener('drop', (ev)=>{
+ this.#addRow();
+ handleDrop(ev, this.#rows[this.#rows.length-1]);
+ });*/
+ eDropzone.addEventListener('drop', handleDrop);
+ const pasteImage = (event, item)=>{
+ if( item.type.indexOf('image') === 0 ) {
+ event.preventDefault();
+ const blob = item.getAsFile();
+ if( blob.name === 'image.png' ){
+ /* Workaround to attempt to avoid name collisions when pasting
+ multiple images. We cannot, at this level, unambiguously
+ distinguish a ctrl-v of bitmap data vs a ctrl-v of an image
+ file copied via a desktop file manager. */
+ rowObj.overrideName = `pasted-image-${Date.now()}.png`;
+ }
+ this.#injestBlob(rowObj, blob);
+ return true;
+ }
+ return false;
+ };
+ const pasteThing = (event, thing)=>{
+ if( pasteImage(event, thing) ) return true;
+ if( 'file' === thing.kind ){
+ event.preventDefault();
+ const blob = thing.getAsFile();
+ if( blob ){
+ this.#injestBlob(rowObj, blob);
+ return true;
+ }
+ }
+ return false;
+ };
+ eDesc?.addEventListener?.('paste', (e) => {
+ e.stopPropagation();
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
+ if( !items ) return;
+ for( let i = 0; i < items.length; ++i ){
+ const item = items[i];
+ if( pasteThing(e, item) ){
+ break;
+ }
+ }
+ });
+ eRow.addEventListener('paste', (e) => {
+ const items = (e.clipboardData || e.originalEvent.clipboardData)?.items;
+ if( !items ) return;
+ for( let i = 0; i < items.length; ++i ){
+ const item = items[i];
+ if( item.type === 'text/plain' ){
+ e.preventDefault();
+ item.getAsString((text) => {
+ rowObj.overrideName = `pasted-text-${Date.now()}.txt`;
+ const blob = new File([text], rowObj.overrideName,
+ {type: 'text/plain'});
+ this.#injestBlob(rowObj, blob);
+ });
+ break;
+ }else if( pasteThing(e, item) ){
+ break;
+ }
+ }
+ });
+ eRow.append(eDropzone);
+ if( eDesc ) eRow.append(eDesc);
+ rowObj.e = F.nu({
+ dropzone: eDropzone,
+ info: eInfo,
+ filename: eFilename,
+ size: eSize,
+ desc: eDesc,
+ row: eRow,
+ remove: eRemove
+ });
+ this.#e.body.append(eRow);
+ eRow.classList.add('animate-entrance');
+ requestAnimationFrame(() => {
+ eRow.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'nearest'
+ });
+ });
+
+ this.#rows.push( rowObj );
+ this.#updateControls();
+ this.#events.dispatchEvent(
+ new CustomEvent('entry-added',{
+ detail: F.nu({
+ type: 'entry-added',
+ row: rowObj,
+ attacher: this
+ })
+ })
+ );
+ if( 0 ){
+ /* To allow immediate ctrl-v, we need a trick...
+ But don't do this because it will interfere with, e.g.,
+ the forum editor. */
+ D.attr(eRow, 'tabindex', '-1');
+ eRow.focus();
+ }
+ }
+
+ #rowMatchingName(name){
+ for(let r of this.#rows){
+ if( r.file?.name===name ) return r;
+ }
+ }
+
+ /**
+ Injects the given File object as the attached content for the
+ given row. If the object's name collides with another row,
+ rowObj is removed from this widget and the old row is instead
+ re-populated with the new file.
+
+ If rowObj.overrideName is set then the given file gets wrapped
+ with that name before attaching it, and that property is
+ removed from rowObj. This is intended only for communicating
+ auto-generated names for pasted data.
+ */
+ #injestBlob(rowObj, file){
+ if( !file ) return;
+ const old = this.#rowMatchingName(file.name);
+ if( rowObj.overrideName ){
+ if( rowObj.overrideName !== file.name ){
+ file = new File([file], rowObj.overrideName, {type: file.type});
+ }
+ rowObj.overrideName = undefined;
+ }
+ if( old && rowObj !== old ){
+ /*
+ Fossil attachments treat the name as a unique-per-target
+ key, with the newest one being the primary. If a name is
+ given twice, remove the new entry and reuse the older
+ one. There are conceivable, but also unlikely, cases where
+ this will have unintended side-effects, e.g. attaching both
+ /foo/bar and /baz/bar, but that seems like a lesser evil
+ than attaching the same file N times, leading to N
+ attachment artifacts.
+ */
+ /* recycle `old` instead to avoid UI flicker. */
+ this.#rowError(old);
+ this.#removeRow(rowObj);
+ rowObj = old;
+ }
+
+ let szLbl;
+ if( file.size < 500000 ){
+ szLbl = file.size + ' bytes';
+ }else if( file.size < 1000000 ){
+ szLbl = (file.size / 1024).toFixed(2)+' KB';
+ }else{
+ szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB';
+ }
+ this.#rowError(rowObj);
+ rowObj.file = file;
+ rowObj.mimeType = file.type || 'application/octet-stream';
+ D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content');
+ D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || '');
+ rowObj.e.dropzone.classList.add('populated');
+ if( rowObj.e.desc ){
+ rowObj.e.desc.classList.remove('hidden');
+ }
+ if( rowObj.e.thumbnail ){
+ rowObj.e.thumbnail.remove();
+ rowObj.e.thumbnail = undefined;
+ }
+ if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){
+ /* Add a thumbnail */
+ const img = rowObj.e.thumbnail = D.img();
+ rowObj.e.dropzone.insertBefore(img, rowObj.e.remove);
+ img.classList.add('thumbnail');
+ const reader = new FileReader();
+ reader.onload = (e)=>img.setAttribute('src', e.target.result);
+ reader.readAsDataURL(file);
+ }
+ if( F.config.attachmentSizeLimit>0
+ && file.size>F.config.attachmentSizeLimit ){
+ /* Problem: tapping this link propagates its click event through
+ to eDropzone. Thus... */
+ const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit');
+ eLink.addEventListener('click', ev=>ev.stopPropagation());
+ this.#rowError(rowObj, "Too large: ", eLink,
+ " is ",F.config.attachmentSizeLimit," bytes");
+ rowObj.ok = false;
+ }else if( !file.size ){
+ this.#rowError(rowObj, "Cannot attach zero-byte files.");
+ rowObj.ok = false;
+ }else{
+ rowObj.ok = true;
+ }
+ this.#events.dispatchEvent(
+ new CustomEvent('entry-populated',{
+ detail: F.nu({
+ type: 'entry-populated',
+ row: rowObj,
+ attacher: this
+ })
+ })
+ );
+ }
+
+ /**
+ Returns an array of objects describing the currently-selected
+ attachments.
+ */
+ collectState(){
+ const rv = [];
+ for(let r of this.#rows){
+ if( !r.e.dropzone?.classList?.contains?.('populated') ){
+ continue;
+ }
+ rv.push(F.nu({
+ name: r.name || r.file.name,
+ content: r.file,
+ description: r.e.desc?.value ?? '',
+ mimeType: r.mimeType
+ }));
+ }
+ return rv;
+ }
+
+ /**
+ Populates the given FormData object with entries named
+ ${namePrefix}${N}, each representing a selected file and N
+ being a 1-based incremental counter. For entries which have a
+ description, it also sets ${namePrefix}${N}_desc.
+ */
+ populateFormData(fd, namePrefix='file'){
+ const st = this.collectState();
+ let i = 0;
+ for( ; i < st.length; ++i){
+ const s = st[i];
+ const suffix = i+1;
+ fd.append(`${namePrefix}${suffix}`, s.content, s.name);
+ const d = s.description?.trim?.();
+ if( d ){
+ fd.append(`${namePrefix}${suffix}_desc`, d);
+ }
+ }
+ return i;
+ }
+ }/*Attacher*/;
+ F.Attacher = Attacher;
+
+ F.onPageLoad(function(){
+ const eAttachWrapper = document.querySelector('#attachadd-form-wrapper');
+ if( eAttachWrapper ){
+ /* This page is /attachadd v2. eAttachWrapper holds
+ input[type=hidden] fields for use in attaching files and is
+ where we inject a file attachment widget. */
+ document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove());
+
+ eAttachWrapper.classList.remove('hidden');
+ const urlArgs = new URLSearchParams(window.location.search);
+ let zTarget = urlArgs.get('target');
+ let zTo = urlArgs.get('to') || urlArgs.get('from');
+ const eBtnSubmit = D.button("Submit");
+ eBtnSubmit.type = 'button';
+ const updateBtnSubmit = (attacher)=>{
+ if( attacher.isPopulated ){
+ eBtnSubmit.removeAttribute('disabled');
+ }else{
+ eBtnSubmit.setAttribute('disabled', '');
+ }
+ };
+ const cbAttacherChange = (ev)=>{
+ const a = ev.detail.attacher;
+ updateBtnSubmit(a);
+ };
+ const att = new Attacher({
+ container: eAttachWrapper,
+ startWith: 1,
+ listener: cbAttacherChange,
+ controls: [eBtnSubmit],
+ description: true
+ });
+ eBtnSubmit.addEventListener('click', async (ev)=>{
+ att.reportError();
+ const li = att.collectState();
+ if( !li.length ) return;
+ if( eBtnSubmit.dataset.submitted ) return;
+ eBtnSubmit.dataset.submitted = 1;
+ D.disable(eBtnSubmit);
+ const fd = new FormData();
+ att.populateFormData(fd);
+ for( const eIn of eAttachWrapper.querySelectorAll(
+ 'input[type="hidden"]'
+ ) ){
+ /* Copy over hidden input fields emitted by the server. */
+ if( eIn.name==='target' ){
+ zTarget = eIn.value;
+ }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){
+ zTo = eIn.value;
+ }
+ fd.append(eIn.name, eIn.value)
+ }
+ if( att.isDryRun ){
+ fd.append('dryrun', '1');
+ }
+ let err;
+ const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), {
+ method: 'POST',
+ body: fd
+ }).catch((e)=>{
+ err = e;
+ });
+ D.enable(eBtnSubmit);
+ delete eBtnSubmit.dataset.submitted;
+ const jr = err ? undefined : await resp.json().catch(()=>{});
+ if( err || jr?.error || !resp.ok ){
+ const msg = err ? err.message : (jr?.error || resp.statusText);
+ att.reportError("Attaching failed: ", msg);
+ }else{
+ att.clear();
+ let to = zTo || jr?.redirect;
+ if( to ){
+ if( '/'!==to[0] ){
+ to = F.repoUrl(to);
+ }
+ window.location = to;
+ }else if( zTarget ){
+ window.location = '?target='+zTarget+'&'+Date.now();
+ }
+ }
+ })/*submit handler*/;
+ updateBtnSubmit(att);
+ F.page.attacher = att /* only for testing via dev console */;
+ }/* /attachadd */
+ })/*onPageLoad()*/;
+
+})(window.fossil);