Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 1b45b477d6 One Chat entry: Signal is listed inside Chat, so the separate Signal tab is gone for people who have both
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:39:04 -04:00
nmemmertandClaude Sonnet 5.5 32dbda5c78 Merge branch 'office-fixes'
build-container / build (push) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:23:02 -04:00
nmemmertandClaude Sonnet 5.5 da6c30fac4 Office: ready-made templates, the file-type badge no longer overlaps the date, and a Full editor and Edit elsewhere option for phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:21:59 -04:00
nmemmertandClaude Sonnet 5.5 16ba5de7c5 Merge branch 'chat-gif-place-inbox'
build-container / build (push) Successful in 3m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:16:46 -04:00
19 changed files with 214 additions and 20 deletions

No files matched your search

+9 -7
View File
@@ -174,7 +174,9 @@ const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['musi
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1]; const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
const chatUnread = () => (ch.unread || 0) + (sgInChat() ? (sg.unread || 0) : 0);
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0]) || (t[0] === 'signal' && sgInChat())) continue; let id = t[0], label = t[1];
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; } if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; }; if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */ /** Every page one by one (what search and the command palette list), with the merged entries split back out. */
@@ -202,13 +204,13 @@ function renderNav(active = activeTab) {
if (typeof sgDockBadge === 'function') sgDockBadge(); if (typeof sgDockBadge === 'function') sgDockBadge();
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere(); if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : ''; const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`; const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(chatUnread()) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`; const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = navTabs(); const mine = navTabs();
if (phone.matches) { if (phone.matches) {
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active); const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') + $('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : ''); (rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(chatUnread()) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); }; const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
} else { } else {
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`; $('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
@@ -219,7 +221,7 @@ phone.addEventListener('change', () => renderNav());
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */ /** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
function openLauncher() { function openLauncher() {
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; }; const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'chat' ? chatUnread() : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`; const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div> const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`); <div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
@@ -255,7 +257,7 @@ function drawSubnav(name) {
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {} try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false; el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
} }
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = ''; let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
@@ -328,7 +330,7 @@ function homeApps() {
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'), podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'), chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'), notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'), chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', chatUnread() ? chatUnread() + ' unread' : sgInChat() ? 'Family and Signal' : 'Family messages'),
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'), store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'), recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'), contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
@@ -340,7 +342,7 @@ function homeApps() {
}; };
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join(''); return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
} }
function viewHome() { function viewHome() {
+2 -1
View File
@@ -351,10 +351,11 @@ async function chSettings() {
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); } let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
const q = pr.quiet, admin = ME && ME.role === 'admin'; const q = pr.quiet, admin = ME && ME.role === 'admin';
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label> const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
${typeof allowed === 'function' && allowed('signal') ? `<a class="row" href="#/signal" id="chsig" style="background:var(--panel2);border-radius:12px;margin:10px 0"><span class="grow"><b>Signal</b><small style="white-space:normal">Link or unlink your Signal account, and start a new Signal conversation. Your Signal chats are in this list.</small></span><span style="color:var(--accent)">Open ›</span></a>` : ''}
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div> <div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div> ${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); <div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#chsx').onclick = closeSheet; el.querySelector('#chsx').onclick = closeSheet; const sgl = el.querySelector('#chsig'); if (sgl) sgl.onclick = () => closeSheet();
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } }; el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
} }
/** Home: the family announcement, if there is one (each person can dismiss it). */ /** Home: the family announcement, if there is one (each person can dismiss it). */
+13 -2
View File
@@ -6,13 +6,23 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join(''); const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view'); const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div> // On a phone the editor normally uses OnlyOffice's phone layout, which on some OnlyOffice editions only lets you read. "Full editor" asks for the desktop layout instead (scaled to fit the screen),
// and "Edit elsewhere" downloads a copy to edit in another app and uploads it back over this file.
const phone = innerWidth < 768, full = phone && store.get('oo_layout', 'auto') === 'full';
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b>${phone ? `<button class="btn ghost" id="edfull" title="${full ? 'Back to the phone layout' : 'Use the full desktop editor (small, but it can edit)'}">${full ? 'Phone view' : 'Full editor'}</button><button class="ibtn" id="edmore" aria-label="Edit elsewhere" title="Edit in another app">${icon('more')}</button>` : ''}</div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`; <div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
if (phone) {
$('#edfull').onclick = () => { store.set('oo_layout', full ? 'auto' : 'full'); viewEdit([repo, ...rawParts], opts); };
$('#edmore').onclick = () => actionSheet(name, 'Edit in another app', [
{ label: 'Download a copy', icon: 'dl', run: () => downloadItem(repo, path) },
{ label: 'Replace with an edited copy…', icon: 'up', run: () => { const f = document.createElement('input'); f.type = 'file'; f.accept = '.' + name.split('.').pop(); f.onchange = async () => { const file = f.files[0]; if (!file) return; try { const dir = path.slice(0, path.lastIndexOf('/')) || '/', link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData(); fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', file, name); const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd }); if (!up.ok) throw new Error('Could not replace it (' + up.status + ')'); toast('Replaced with your edited copy'); viewEdit([repo, ...rawParts], opts); } catch (e) { toast(e.message); } }; f.click(); } },
]);
}
if (!sf.token()) return (location.hash = '#/files'); if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } }; const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
try { try {
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() }, const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) }); body: JSON.stringify({ repo, path, mobile: phone && !full }) });
const conf = await r.json(); const conf = await r.json();
if (!r.ok) return fail(conf.error || 'Could not open document'); if (!r.ok) return fail(conf.error || 'Could not open document');
if (!window.DocsAPI) await new Promise((ok, no) => { if (!window.DocsAPI) await new Promise((ok, no) => {
@@ -22,6 +32,7 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return; if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove(); $('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) }; conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
if (full) { const wrap = $('.edwrap'), W = 1000, k = Math.min(1, wrap.clientWidth / W), box = $('#oo'); wrap.style.overflow = 'hidden'; box.style.cssText = `width:${W}px;height:${Math.round(wrap.clientHeight / k)}px;transform:scale(${k});transform-origin:0 0`; } // (the desktop editor is laid out wide, then shrunk to fit the phone)
editor = new DocsAPI.DocEditor('oo', conf); editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); } } catch (e) { fail(e.message); }
} }
+10
View File
@@ -20,16 +20,26 @@ function ofOpen(repo, path, name) {
} }
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; } function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
/** Ready-made starting points (files in public/templates): the person gets their own copy to fill in. */
const OFFICE_TEMPLATES = [
['letter.docx', 'Letter', 'A formal or friendly letter, laid out', 'docx'], ['resume.docx', 'Resume', 'Summary, experience, education and skills', 'docx'], ['meeting-notes.docx', 'Meeting notes', 'Agenda, notes and action items', 'docx'],
['family-newsletter.docx', 'Family newsletter', 'News, who did what and what is coming up', 'docx'], ['recipe-card.docx', 'Recipe card', 'Ingredients, steps and notes', 'docx'],
['monthly-budget.xlsx', 'Monthly budget', 'Planned against actual, with the math done', 'xlsx'], ['expense-tracker.xlsx', 'Expense tracker', 'One line per purchase, totals by category', 'xlsx'],
['chore-chart.xlsx', 'Chore chart', 'A week of chores with a count for each', 'xlsx'], ['meal-planner.xlsx', 'Weekly meal planner', 'Breakfast, lunch, dinner and a shopping list', 'xlsx'],
['simple-presentation.pptx', 'Simple presentation', 'Title, agenda, key points and a thank-you', 'pptx'],
];
function viewOffice(args = []) { function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
const signed = !!sf.token(); const signed = !!sf.token();
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div> $('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div> <div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
<details class="otplbox" id="otpl"><summary><b>Start from a template</b> <small>${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides</small></summary><div class="otpl">${OFFICE_TEMPLATES.map((t, i) => `<button type="button" class="octile otc" data-t="${i}" style="--c:${OFFICE_KINDS[ofKind('x.' + t[3])].color}">${ofIcon('x.' + t[3])}<span><b>${esc(t[1])}</b><small>${esc(t[2])}</small></span></button>`).join('')}</div></details>
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'} ${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p> <p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div> <div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`; <div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase())); $('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
$('#view').querySelectorAll('.otc').forEach(b => b.onclick = () => { const t = OFFICE_TEMPLATES[+b.dataset.t]; newOfficeDoc(t[3], t[1], t[0], t[1]); });
if (!signed) return; if (!signed) return;
$('#ofup').onclick = () => $('#ofupf').click(); $('#ofup').onclick = () => $('#ofupf').click();
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); }; $('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
+1 -1
View File
@@ -57,7 +57,7 @@ function sgEvent(ev) {
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); } function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
// ... and the same screens in a panel that floats over every other screen. // ... and the same screens in a panel that floats over every other screen.
async function sgMount(root, dock) { async function sgMount(root, dock) {
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>'; sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`; root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; } let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
if (sg.root !== root) return; sg.st = st; if (sg.root !== root) return; sg.st = st;
+3 -1
View File
@@ -689,7 +689,7 @@ body.nav-mini .ngrp span { display: none; }
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; } .ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; } .ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } .orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.octile.rc { padding: 10px 12px; } .octile.rc { padding: 10px 12px; } .octile .ic.ofic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; flex: none; } .octile .ic.ofic svg { width: 20px; height: 20px; margin-top: -5px; } .octile .ic.ofic em { bottom: 3px; }
.orow .rmain { min-width: 0; } .orow .rmain { min-width: 0; }
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; } #ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
@@ -1109,3 +1109,5 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; } .paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); } .gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; } .srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v130'; const CACHE = 'hub-v133';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+7 -7
View File
@@ -193,26 +193,26 @@ function chooseFolder(title, start, opts = {}) {
} }
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor). // Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
async function createFromTemplate(repo, dir, name, x) { async function createFromTemplate(repo, dir, name, x, tpl) {
const enc = encodeURIComponent; const enc = encodeURIComponent;
if (!['docx', 'xlsx', 'pptx'].includes(x)) { if (!['docx', 'xlsx', 'pptx'].includes(x)) {
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) }); await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
return name; return name;
} }
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing'); const tplRes = await fetch('/templates/' + (tpl || 'blank.' + x)); if (!tplRes.ok) throw new Error('Template missing');
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`); const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name); const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tplRes.blob(), name);
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd }); const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Could not create ' + name); if (!r.ok) throw new Error('Could not create ' + name);
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
} }
async function newOfficeDoc(x, label) { async function newOfficeDoc(x, label, tpl, defName) {
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; } if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
let loc; let loc;
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); } try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
if (!loc) return; if (!loc) return;
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required> const el = openSheet(tpl ? 'New from template' : 'New ' + label, tpl ? label : '', `<form><label for="nn">File name</label><input type="text" id="nn" value="${esc(defName || 'Untitled')}" autocomplete="off" autocapitalize="off" required>
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button> <label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`); <div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); }; const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
@@ -221,7 +221,7 @@ async function newOfficeDoc(x, label) {
el.querySelector('#nl').onclick = async () => { el.querySelector('#nl').onclick = async () => {
// Sheets don't stack, so close this one, choose, then reopen with the new location. // Sheets don't stack, so close this one, choose, then reopen with the new location.
const picked = await chooseFolder('Save in…', loc); const picked = await chooseFolder('Save in…', loc);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label); if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label, tpl, defName);
}; };
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50); setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
el.querySelector('form').onsubmit = async e => { el.querySelector('form').onsubmit = async e => {
@@ -230,7 +230,7 @@ async function newOfficeDoc(x, label) {
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try { try {
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name); const name = await createFromTemplate(loc.repo, loc.dir, want, x, tpl), path = joinP(loc.dir, name);
addRecent(loc.repo, path, name); closeSheet(); addRecent(loc.repo, path, name); closeSheet();
if (cfg.editor) location.hash = officeHash(loc.repo, path); if (cfg.editor) location.hash = officeHash(loc.repo, path);
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+168
View File
@@ -0,0 +1,168 @@
# Makes the ready-made Office templates in public/templates. Needs: pip install python-docx openpyxl python-pptx. Run: python3 tools/make-office-templates.py public/templates
import sys, os
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from pptx import Presentation
from pptx.util import Inches as PI, Pt as PP
from pptx.dml.color import RGBColor as PC
OUT = sys.argv[1]; os.makedirs(OUT, exist_ok=True)
BLUE = '3D6BF0'
# ---------------- Word
def base(doc, size=11):
st = doc.styles['Normal']; st.font.name = 'Calibri'; st.font.size = Pt(size)
for s in doc.sections: s.left_margin = s.right_margin = Inches(1); s.top_margin = s.bottom_margin = Inches(0.9)
def para(doc, text='', bold=False, size=None, color=None, align=None, after=6, italic=False):
p = doc.add_paragraph(); r = p.add_run(text); r.bold = bold; r.italic = italic
if size: r.font.size = Pt(size)
if color: r.font.color.rgb = RGBColor.from_string(color)
if align: p.alignment = align
p.paragraph_format.space_after = Pt(after); return p
def heading(doc, text, size=13):
p = para(doc, text.upper(), bold=True, size=size - 2, color=BLUE, after=3); p.paragraph_format.space_before = Pt(10)
return p
d = Document(); base(d, 11)
para(d, '[Your name]', bold=True, size=14, after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=0); para(d, '[Phone] · [Email]', after=14)
para(d, '[Date]', after=14); para(d, '[Recipient name]', after=0); para(d, '[Title or company]', after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=14)
para(d, 'Dear [Name],', after=10)
para(d, 'Start with the reason you are writing, in a sentence or two. Keep it friendly and direct.', after=10)
para(d, 'Use the middle paragraph for the details: what you need, what happened, or what you are offering. Short paragraphs are easier to read.', after=10)
para(d, 'Close by saying what happens next, and thank them for their time.', after=14)
para(d, 'Sincerely,', after=30); para(d, '[Your name]', after=0)
d.save(f'{OUT}/letter.docx')
d = Document(); base(d, 10.5)
para(d, '[Your Name]', bold=True, size=24, after=0); para(d, '[City, State] · [Phone] · [Email]', color='666666', after=8)
heading(d, 'Summary'); para(d, 'Two or three sentences about who you are, what you do well, and what you are looking for.')
heading(d, 'Experience')
for i in range(2):
para(d, '[Job title], [Employer]', bold=True, after=0); para(d, '[Month Year] – [Month Year]', italic=True, color='666666', after=3)
for t in ['What you did and the result it had (use numbers when you can)', 'Something you improved, built or led', 'A skill you used every day']:
p = d.add_paragraph(t, style='List Bullet'); p.paragraph_format.space_after = Pt(1)
para(d, '', after=4)
heading(d, 'Education'); para(d, '[School], [Degree or program]', bold=True, after=0); para(d, '[Year]', italic=True, color='666666')
heading(d, 'Skills'); para(d, '[Skill] · [Skill] · [Skill] · [Skill] · [Skill]')
d.save(f'{OUT}/resume.docx')
d = Document(); base(d, 11)
para(d, 'Meeting notes', bold=True, size=22, color=BLUE, after=2); para(d, '[Meeting name]', size=13, after=10)
t = d.add_table(rows=4, cols=2); t.style = 'Light Grid Accent 1'
for i, (a, b) in enumerate([('Date', '[Date and time]'), ('Where', '[Place or link]'), ('Who was there', '[Names]'), ('Taking notes', '[Name]')]): t.cell(i, 0).text = a; t.cell(i, 1).text = b
para(d, '', after=4); heading(d, 'Agenda')
for x in ['[First topic]', '[Second topic]', '[Third topic]']: d.add_paragraph(x, style='List Number')
heading(d, 'Notes'); para(d, 'What was said and what was decided.', italic=True, color='666666'); para(d, '', after=30)
heading(d, 'Action items')
t = d.add_table(rows=4, cols=3); t.style = 'Light Grid Accent 1'
for i, h in enumerate(['What needs doing', 'Who', 'By when']): t.cell(0, i).text = h
d.save(f'{OUT}/meeting-notes.docx')
d = Document(); base(d, 11)
para(d, '[Family Name] News', bold=True, size=28, color=BLUE, align=WD_ALIGN_PARAGRAPH.CENTER, after=0); para(d, '[Month Year]', align=WD_ALIGN_PARAGRAPH.CENTER, color='666666', after=14)
heading(d, 'The big news'); para(d, 'Share the headline: a trip, a birthday, a new job, a new pet. A few friendly sentences is plenty.')
heading(d, 'What everyone has been up to')
for n in ['[Name]', '[Name]', '[Name]']: para(d, n, bold=True, after=0); para(d, 'A line or two about school, work, hobbies or something funny.', after=8)
heading(d, 'Coming up'); t = d.add_table(rows=4, cols=2); t.style = 'Light List Accent 1'
t.cell(0, 0).text = 'Date'; t.cell(0, 1).text = 'What'
heading(d, 'A favorite moment'); para(d, 'Add a photo here (Insert → Image) and a sentence about it.', italic=True, color='666666')
d.save(f'{OUT}/family-newsletter.docx')
d = Document(); base(d, 11)
para(d, '[Recipe name]', bold=True, size=24, color=BLUE, after=2); para(d, 'Serves [4] · Prep [15 min] · Cook [30 min]', color='666666', after=10)
heading(d, 'Ingredients')
for x in ['[2 cups flour]', '[1 tsp salt]', '[1 cup milk]', '[2 eggs]']: d.add_paragraph(x, style='List Bullet')
heading(d, 'Steps')
for x in ['[First step]', '[Second step]', '[Third step]']: d.add_paragraph(x, style='List Number')
heading(d, 'Notes'); para(d, 'Where it came from, what to change next time, what goes well with it.', italic=True, color='666666')
d.save(f'{OUT}/recipe-card.docx')
# ---------------- Excel
thin = Side(style='thin', color='D0D5DD'); BOX = Border(left=thin, right=thin, top=thin, bottom=thin)
HEAD = PatternFill('solid', fgColor=BLUE); SOFT = PatternFill('solid', fgColor='EEF2FF')
def hdr(ws, row, values, widths=None):
for i, v in enumerate(values, 1):
c = ws.cell(row=row, column=i, value=v); c.font = Font(bold=True, color='FFFFFF'); c.fill = HEAD; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True); c.border = BOX
if widths:
for i, w in enumerate(widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
def title(ws, text, sub=None):
ws['A1'] = text; ws['A1'].font = Font(bold=True, size=18, color=BLUE)
if sub: ws['A2'] = sub; ws['A2'].font = Font(italic=True, color='666666')
wb = Workbook(); ws = wb.active; ws.title = 'Budget'; title(ws, 'Monthly budget', 'Fill in the planned and actual amounts. The last column does the math.')
ws['A4'] = 'Income'; ws['A4'].font = Font(bold=True, size=12)
hdr(ws, 5, ['Item', 'Planned', 'Actual', 'Difference'], [30, 14, 14, 14])
inc = ['Paycheck', 'Second income', 'Other']
for i, n in enumerate(inc):
r = 6 + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=C{r}-B{r}')
r = 6 + len(inc); ws.cell(r, 1, 'Total income').font = Font(bold=True); ws.cell(r, 2, f'=SUM(B6:B{r-1})'); ws.cell(r, 3, f'=SUM(C6:C{r-1})'); ws.cell(r, 4, f'=C{r}-B{r}'); inc_total = r
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
start = r + 2; ws.cell(start, 1, 'Expenses').font = Font(bold=True, size=12); hdr(ws, start + 1, ['Item', 'Planned', 'Actual', 'Difference'])
exp = ['Rent or mortgage', 'Utilities', 'Groceries', 'Transportation', 'Insurance', 'Phone and internet', 'Eating out', 'Kids and school', 'Health', 'Giving', 'Savings', 'Fun', 'Other']
first = start + 2
for i, n in enumerate(exp):
r = first + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=B{r}-C{r}')
last = first + len(exp) - 1; r = last + 1; ws.cell(r, 1, 'Total expenses'); ws.cell(r, 2, f'=SUM(B{first}:B{last})'); ws.cell(r, 3, f'=SUM(C{first}:C{last})'); ws.cell(r, 4, f'=B{r}-C{r}'); exp_total = r
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
r += 2; ws.cell(r, 1, 'Left over (income − expenses)').font = Font(bold=True, size=12); ws.cell(r, 2, f'=B{inc_total}-B{exp_total}'); ws.cell(r, 3, f'=C{inc_total}-C{exp_total}')
for row in ws.iter_rows(min_row=6, max_row=r):
for c in row[1:4]: c.number_format = '$#,##0.00;[Red]-$#,##0.00'
for c in row[:4]:
if c.row not in (start, start + 1, r) and c.value is not None: c.border = BOX
ws.freeze_panes = 'A6'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/monthly-budget.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Chores'; title(ws, 'Weekly chore chart', 'Put a name in each box, or a check mark when it is done.')
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; hdr(ws, 4, ['Chore'] + days + ['Points'], [26] + [10] * 7 + [10])
chores = ['Make bed', 'Set the table', 'Clear the table', 'Dishes / dishwasher', 'Feed the pets', 'Take out trash', 'Tidy bedroom', 'Laundry', 'Vacuum', 'Water plants']
for i, n in enumerate(chores):
r = 5 + i; ws.cell(r, 1, n)
for c in range(1, 10): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(horizontal='center' if c > 1 else 'left')
ws.cell(r, 9, f'=COUNTA(B{r}:H{r})')
r = 5 + len(chores); ws.cell(r, 1, 'Total').font = Font(bold=True); ws.cell(r, 9, f'=SUM(I5:I{r-1})'); ws.cell(r, 9).font = Font(bold=True)
for c in range(1, 10): ws.cell(r, c).fill = SOFT; ws.cell(r, c).border = BOX
ws.freeze_panes = 'B5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/chore-chart.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Meals'; title(ws, 'Weekly meal planner', 'Plan the week, then build the shopping list from it.')
hdr(ws, 4, ['', 'Breakfast', 'Lunch', 'Dinner', 'Snacks'], [14, 24, 24, 28, 22])
for i, d_ in enumerate(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']):
r = 5 + i; ws.cell(r, 1, d_).font = Font(bold=True); ws.cell(r, 1).fill = SOFT
for c in range(1, 6): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(vertical='top', wrap_text=True)
ws.row_dimensions[r].height = 34
ws.cell(13, 1, 'Shopping list').font = Font(bold=True, size=12); ws.cell(14, 1, 'Write what you need to buy under here.').font = Font(italic=True, color='666666')
ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/meal-planner.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Expenses'; title(ws, 'Expense tracker', 'One line per purchase. The totals below add themselves up.')
hdr(ws, 4, ['Date', 'What', 'Category', 'Paid with', 'Amount'], [14, 32, 18, 16, 14])
for r in range(5, 45):
for c in range(1, 6): ws.cell(r, c).border = BOX
ws.cell(r, 1).number_format = 'mmm d, yyyy'; ws.cell(r, 5).number_format = '$#,##0.00'
ws['D46'] = 'Total'; ws['D46'].font = Font(bold=True); ws['E46'] = '=SUM(E5:E44)'; ws['E46'].number_format = '$#,##0.00'; ws['E46'].font = Font(bold=True)
ws['G4'] = 'By category'; ws['G4'].font = Font(bold=True, color='FFFFFF'); ws['G4'].fill = HEAD; ws['H4'] = 'Total'; ws['H4'].font = Font(bold=True, color='FFFFFF'); ws['H4'].fill = HEAD; ws.column_dimensions['G'].width = 18; ws.column_dimensions['H'].width = 14
for i, n in enumerate(['Groceries', 'Gas', 'Eating out', 'Home', 'Kids', 'Health', 'Fun', 'Other']):
r = 5 + i; ws.cell(r, 7, n); ws.cell(r, 8, f'=SUMIF($C$5:$C$44,G{r},$E$5:$E$44)'); ws.cell(r, 8).number_format = '$#,##0.00'
ws.freeze_panes = 'A5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/expense-tracker.xlsx')
# ---------------- PowerPoint
prs = Presentation(); prs.slide_width = PI(13.333); prs.slide_height = PI(7.5)
def slide(title_text, lines=None, layout=1, sub=None):
s = prs.slides.add_slide(prs.slide_layouts[layout]); s.shapes.title.text = title_text
for p in s.shapes.title.text_frame.paragraphs:
for r in p.runs: r.font.color.rgb = PC.from_string(BLUE); r.font.bold = True; r.font.name = 'Calibri'
if layout == 0 and sub is not None: s.placeholders[1].text = sub
elif lines is not None:
tf = s.placeholders[1].text_frame; tf.clear()
for i, t in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.text = t; p.font.size = PP(26); p.font.name = 'Calibri'
return s
slide('[Presentation title]', layout=0, sub='[Your name] · [Date]')
slide('Agenda', ['[First topic]', '[Second topic]', '[Third topic]', 'Questions'])
slide('[A key point]', ['Say it in one clear sentence', 'Back it up with two or three short points', 'Add a picture or a chart on the right (Insert → Image)'])
slide('[Another key point]', ['Keep to a few words per line', 'Let the pictures do the talking', 'Practice saying the rest out loud'])
slide('Summary', ['What we covered', 'What happens next', 'Who does what'])
slide('Thank you', layout=0, sub='[Contact or question prompt]')
prs.save(f'{OUT}/simple-presentation.pptx')
print(sorted(os.listdir(OUT)))