PDF: separate display name and filename; show filename in picker; fix rename
- Upload dialog now has distinct display name + filename fields, both pre-filled from the uploaded file but independently editable - Rename in file picker shows and edits both display name and filename separately - Filename conflict checked against both KV store and disk (via rename_no_replace) - Display name and filename are fully independent — no longer derived from each other - Add find_pdf_references() helper in storage.mjs for future use - CSS: fp-name-wrap shows display name + dim monospace filename below it; rename mode stacks two inputs; fp-field-label for upload form labels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,11 @@ export function delete_pdf(id) {
|
|||||||
return store.delete(`pdf:${id}`);
|
return store.delete(`pdf:${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns all components that reference the given PDF id in their file_ids array.
|
||||||
|
export function find_pdf_references(pdf_id) {
|
||||||
|
return list_components().filter(c => c.file_ids?.includes(pdf_id));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Grid images ---
|
// --- Grid images ---
|
||||||
|
|
||||||
export function list_grid_images() {
|
export function list_grid_images() {
|
||||||
|
|||||||
@@ -1479,32 +1479,49 @@ function render_file_picker_list() {
|
|||||||
row.appendChild(thumb);
|
row.appendChild(thumb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const name_wrap = document.createElement('div');
|
||||||
|
name_wrap.className = 'fp-name-wrap';
|
||||||
const name_el = document.createElement('span');
|
const name_el = document.createElement('span');
|
||||||
name_el.className = 'fp-name';
|
name_el.className = 'fp-name';
|
||||||
name_el.textContent = pdf.display_name;
|
name_el.textContent = pdf.display_name;
|
||||||
|
const filename_el = document.createElement('span');
|
||||||
|
filename_el.className = 'fp-filename';
|
||||||
|
filename_el.textContent = pdf.filename;
|
||||||
|
name_wrap.append(name_el, filename_el);
|
||||||
|
|
||||||
const rename_btn = document.createElement('button');
|
const rename_btn = document.createElement('button');
|
||||||
rename_btn.type = 'button';
|
rename_btn.type = 'button';
|
||||||
rename_btn.className = 'btn btn-secondary btn-sm';
|
rename_btn.className = 'btn btn-secondary btn-sm';
|
||||||
rename_btn.textContent = 'Rename';
|
rename_btn.textContent = 'Rename';
|
||||||
rename_btn.addEventListener('click', () => {
|
rename_btn.addEventListener('click', () => {
|
||||||
const input = document.createElement('input');
|
const name_input = document.createElement('input');
|
||||||
input.type = 'text';
|
name_input.type = 'text';
|
||||||
input.className = 'fp-rename-input';
|
name_input.className = 'fp-rename-input';
|
||||||
input.value = pdf.display_name;
|
name_input.value = pdf.display_name;
|
||||||
name_el.replaceWith(input);
|
name_input.placeholder = 'Display name';
|
||||||
|
|
||||||
|
const filename_input = document.createElement('input');
|
||||||
|
filename_input.type = 'text';
|
||||||
|
filename_input.className = 'fp-rename-input fp-rename-filename';
|
||||||
|
filename_input.value = pdf.filename;
|
||||||
|
filename_input.placeholder = 'filename.pdf';
|
||||||
|
filename_input.spellcheck = false;
|
||||||
|
|
||||||
|
name_wrap.replaceChildren(name_input, filename_input);
|
||||||
rename_btn.textContent = 'Save';
|
rename_btn.textContent = 'Save';
|
||||||
input.focus();
|
name_input.focus();
|
||||||
input.select();
|
name_input.select();
|
||||||
|
|
||||||
const do_rename = async () => {
|
const do_rename = async () => {
|
||||||
const new_name = input.value.trim();
|
const new_display = name_input.value.trim();
|
||||||
if (!new_name || new_name === pdf.display_name) {
|
const new_filename = filename_input.value.trim();
|
||||||
|
if (!new_display || !new_filename) { render_file_picker_list(); return; }
|
||||||
|
if (new_display === pdf.display_name && new_filename === pdf.filename) {
|
||||||
render_file_picker_list();
|
render_file_picker_list();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api.rename_pdf(pdf.id, new_name);
|
const result = await api.rename_pdf(pdf.id, new_display, new_filename);
|
||||||
const idx = all_pdfs.findIndex(p => p.id === pdf.id);
|
const idx = all_pdfs.findIndex(p => p.id === pdf.id);
|
||||||
if (idx !== -1) all_pdfs[idx] = result.pdf;
|
if (idx !== -1) all_pdfs[idx] = result.pdf;
|
||||||
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
@@ -1515,10 +1532,10 @@ function render_file_picker_list() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
rename_btn.onclick = do_rename;
|
rename_btn.onclick = do_rename;
|
||||||
input.addEventListener('keydown', (e) => {
|
[name_input, filename_input].forEach(inp => inp.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') { do_rename(); }
|
if (e.key === 'Enter') { do_rename(); }
|
||||||
if (e.key === 'Escape') { render_file_picker_list(); }
|
if (e.key === 'Escape') { render_file_picker_list(); }
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const select_btn = document.createElement('button');
|
const select_btn = document.createElement('button');
|
||||||
@@ -1542,7 +1559,7 @@ function render_file_picker_list() {
|
|||||||
render_file_picker_list();
|
render_file_picker_list();
|
||||||
});
|
});
|
||||||
|
|
||||||
row.append(name_el, rename_btn, select_btn, del_btn);
|
row.append(name_wrap, rename_btn, select_btn, del_btn);
|
||||||
return row;
|
return row;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -2062,22 +2079,32 @@ async function init() {
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const name_input = document.getElementById('fp-upload-name');
|
const name_input = document.getElementById('fp-upload-name');
|
||||||
|
const filename_input = document.getElementById('fp-upload-filename');
|
||||||
if (!name_input.value.trim()) {
|
if (!name_input.value.trim()) {
|
||||||
name_input.value = file.name.replace(/\.pdf$/i, '');
|
name_input.value = file.name.replace(/\.pdf$/i, '');
|
||||||
}
|
}
|
||||||
|
if (!filename_input.value.trim()) {
|
||||||
|
filename_input.value = file.name;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
qs(document.getElementById('dialog-file-picker'), '#fp-upload-btn').addEventListener('click', async () => {
|
qs(document.getElementById('dialog-file-picker'), '#fp-upload-btn').addEventListener('click', async () => {
|
||||||
const file_input = document.getElementById('fp-file-input');
|
const file_input = document.getElementById('fp-file-input');
|
||||||
const name_input = document.getElementById('fp-upload-name');
|
const name_input = document.getElementById('fp-upload-name');
|
||||||
|
const filename_input = document.getElementById('fp-upload-filename');
|
||||||
const file = file_input.files[0];
|
const file = file_input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
const display_name = name_input.value.trim();
|
||||||
|
const filename = filename_input.value.trim();
|
||||||
|
if (!display_name) { alert('Display name is required.'); return; }
|
||||||
|
if (!filename) { alert('Filename is required.'); return; }
|
||||||
try {
|
try {
|
||||||
const result = await api.upload_pdf(file, name_input.value.trim() || null);
|
const result = await api.upload_pdf(file, display_name, filename);
|
||||||
all_pdfs.push(result.pdf);
|
all_pdfs.push(result.pdf);
|
||||||
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
file_input.value = '';
|
file_input.value = '';
|
||||||
name_input.value = '';
|
name_input.value = '';
|
||||||
|
filename_input.value = '';
|
||||||
render_file_picker_list();
|
render_file_picker_list();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ export const delete_component_template = (id) => req('DELETE', `/api/component-
|
|||||||
|
|
||||||
// PDF files
|
// PDF files
|
||||||
export const get_pdfs = () => req('GET', '/api/pdfs');
|
export const get_pdfs = () => req('GET', '/api/pdfs');
|
||||||
export const rename_pdf = (id, display_name) => req('PUT', `/api/pdfs/${id}`, { display_name });
|
export const rename_pdf = (id, display_name, filename) => req('PUT', `/api/pdfs/${id}`, { display_name, filename });
|
||||||
export const delete_pdf = (id) => req('DELETE', `/api/pdfs/${id}`);
|
export const delete_pdf = (id) => req('DELETE', `/api/pdfs/${id}`);
|
||||||
|
|
||||||
export async function upload_pdf(file, display_name) {
|
export async function upload_pdf(file, display_name, filename) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
if (display_name) form.append('display_name', display_name);
|
if (display_name) form.append('display_name', display_name);
|
||||||
|
if (filename) form.append('filename', filename);
|
||||||
const res = await fetch('/api/pdfs', { method: 'POST', body: form });
|
const res = await fetch('/api/pdfs', { method: 'POST', body: form });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
|
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
|
||||||
|
|||||||
@@ -1701,22 +1701,44 @@ nav {
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fp-name {
|
.fp-name-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-name {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fp-filename {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.fp-rename-input {
|
.fp-rename-input {
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border-focus);
|
border: 1px solid var(--border-focus);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-rename-filename {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fp-upload-section {
|
.fp-upload-section {
|
||||||
@@ -1729,7 +1751,14 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-field-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fp-upload-row input[type=text] {
|
.fp-upload-row input[type=text] {
|
||||||
|
|||||||
@@ -566,7 +566,16 @@
|
|||||||
<div class="form-section-label">Upload new PDF</div>
|
<div class="form-section-label">Upload new PDF</div>
|
||||||
<div class="fp-upload-row">
|
<div class="fp-upload-row">
|
||||||
<input type="file" id="fp-file-input" accept=".pdf,application/pdf">
|
<input type="file" id="fp-file-input" accept=".pdf,application/pdf">
|
||||||
<input type="text" id="fp-upload-name" placeholder="Display name" autocomplete="off">
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<label class="fp-field-label">Display name</label>
|
||||||
|
<input type="text" id="fp-upload-name" placeholder="Human readable label" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
|
<label class="fp-field-label">Filename on disk</label>
|
||||||
|
<input type="text" id="fp-upload-filename" placeholder="e.g. lm741.pdf" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="fp-upload-row">
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="fp-upload-btn">Upload</button>
|
<button type="button" class="btn btn-primary btn-sm" id="fp-upload-btn">Upload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
server.mjs
47
server.mjs
@@ -526,27 +526,27 @@ app.get('/api/pdfs', (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
|
app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
|
||||||
if (!req.file) return fail(res, 'no file uploaded');
|
if (!req.file) return fail(res, 'no file uploaded');
|
||||||
const display_name = (req.body.display_name?.trim() || req.file.originalname).trim();
|
const display_name = req.body.display_name?.trim() || req.file.originalname;
|
||||||
if (list_pdfs().some(p => p.display_name === display_name)) {
|
const filename = sanitize_pdf_filename(req.body.filename?.trim() || req.file.originalname);
|
||||||
|
const all = list_pdfs();
|
||||||
|
if (all.some(p => p.display_name === display_name)) {
|
||||||
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
||||||
return fail(res, 'a file with that name already exists');
|
return fail(res, 'a file with that display name already exists');
|
||||||
|
}
|
||||||
|
if (all.some(p => p.filename === filename)) {
|
||||||
|
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
||||||
|
return fail(res, 'a file with that filename already exists');
|
||||||
}
|
}
|
||||||
const id = generate_id();
|
const id = generate_id();
|
||||||
const filename = sanitize_pdf_filename(display_name);
|
|
||||||
const temp_path = join('./data/pdfs', req.file.filename);
|
const temp_path = join('./data/pdfs', req.file.filename);
|
||||||
const final_path = join('./data/pdfs', filename);
|
const final_path = join('./data/pdfs', filename);
|
||||||
if (!rename_no_replace(temp_path, final_path)) return fail(res, 'a file with that name already exists on disk');
|
if (!rename_no_replace(temp_path, final_path)) {
|
||||||
|
try { unlinkSync(temp_path); } catch {}
|
||||||
|
return fail(res, 'a file with that filename already exists on disk');
|
||||||
|
}
|
||||||
const thumb_prefix = join('./data/pdfs', id + '-thumb');
|
const thumb_prefix = join('./data/pdfs', id + '-thumb');
|
||||||
const thumb_file = generate_pdf_thumb(final_path, thumb_prefix);
|
const thumb_file = generate_pdf_thumb(final_path, thumb_prefix);
|
||||||
const pdf = {
|
const pdf = { id, filename, display_name, original_name: req.file.originalname, size: req.file.size, thumb_filename: thumb_file, uploaded_at: Date.now() };
|
||||||
id,
|
|
||||||
filename,
|
|
||||||
display_name,
|
|
||||||
original_name: req.file.originalname,
|
|
||||||
size: req.file.size,
|
|
||||||
thumb_filename: thumb_file,
|
|
||||||
uploaded_at: Date.now(),
|
|
||||||
};
|
|
||||||
set_pdf(pdf);
|
set_pdf(pdf);
|
||||||
ok(res, { pdf });
|
ok(res, { pdf });
|
||||||
});
|
});
|
||||||
@@ -555,16 +555,19 @@ app.put('/api/pdfs/:id', (req, res) => {
|
|||||||
const pdf = get_pdf(req.params.id);
|
const pdf = get_pdf(req.params.id);
|
||||||
if (!pdf) return fail(res, 'not found', 404);
|
if (!pdf) return fail(res, 'not found', 404);
|
||||||
const display_name = req.body.display_name?.trim();
|
const display_name = req.body.display_name?.trim();
|
||||||
|
const filename = req.body.filename?.trim() ? sanitize_pdf_filename(req.body.filename.trim()) : pdf.filename;
|
||||||
if (!display_name) return fail(res, 'display_name is required');
|
if (!display_name) return fail(res, 'display_name is required');
|
||||||
if (list_pdfs().some(p => p.display_name === display_name && p.id !== pdf.id)) {
|
const all = list_pdfs();
|
||||||
return fail(res, 'a file with that name already exists');
|
if (all.some(p => p.display_name === display_name && p.id !== pdf.id))
|
||||||
|
return fail(res, 'a file with that display name already exists');
|
||||||
|
if (all.some(p => p.filename === filename && p.id !== pdf.id))
|
||||||
|
return fail(res, 'a file with that filename already exists');
|
||||||
|
if (filename !== pdf.filename) {
|
||||||
|
const new_path = join('./data/pdfs', filename);
|
||||||
|
if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path))
|
||||||
|
return fail(res, 'a file with that filename already exists on disk');
|
||||||
}
|
}
|
||||||
const new_filename = sanitize_pdf_filename(display_name);
|
const updated = { ...pdf, display_name, filename };
|
||||||
if (new_filename !== pdf.filename) {
|
|
||||||
const new_path = join('./data/pdfs', new_filename);
|
|
||||||
if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path)) return fail(res, 'a file with that name already exists on disk');
|
|
||||||
}
|
|
||||||
const updated = { ...pdf, display_name, filename: new_filename };
|
|
||||||
set_pdf(updated);
|
set_pdf(updated);
|
||||||
ok(res, { pdf: updated });
|
ok(res, { pdf: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user