diff --git a/public/files.js b/public/files.js index b11ffa5..5dd2000 100644 --- a/public/files.js +++ b/public/files.js @@ -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(''); document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor const v = $('#view'); - v.innerHTML = `
${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}${esc(name)}
+ // 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 = `
${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}${esc(name)}${phone ? `` : ''}

Opening…

`; + 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'); const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } }; try { 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(); if (!r.ok) return fail(conf.error || 'Could not open document'); 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; $('#oomsg').remove(); 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); } catch (e) { fail(e.message); } } diff --git a/public/office.js b/public/office.js index 13531e0..975ba4b 100644 --- a/public/office.js +++ b/public/office.js @@ -20,16 +20,26 @@ function ofOpen(repo, path, name) { } function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `${icon('doc')}${esc(ext(name).slice(0, 4).toUpperCase())}`; } +/** 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 = []) { if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true }); const signed = !!sf.token(); $('#view').innerHTML = `

Office

${signed ? `` : ''}${icon('files')}All files
${Object.entries(OFFICE_KINDS).map(([k, o]) => ``).join('')}
+
Start from a template ${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides
${OFFICE_TEMPLATES.map((t, i) => ``).join('')}
${cfg.editor ? '' : '

Editing inside the app is switched off on this server, so documents open in Seafile instead.

'}

${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}

${signed ? '' : '
Sign in
'}
`; $('#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; $('#ofup').onclick = () => $('#ofupf').click(); $('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); }; diff --git a/public/style.css b/public/style.css index d549ad3..e039e88 100644 --- a/public/style.css +++ b/public/style.css @@ -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 input[type=search] { flex: 1; min-width: 180px; margin: 0; } .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; } #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; } .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; } +.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; } diff --git a/public/sw.js b/public/sw.js index 7402b07..7b14196 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v130'; +const CACHE = 'hub-v132'; 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 SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/tasks.js b/public/tasks.js index 6733559..2fdd00f 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -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). -async function createFromTemplate(repo, dir, name, x) { +async function createFromTemplate(repo, dir, name, x, tpl) { const enc = encodeURIComponent; 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' }) }); 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 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 }); if (!r.ok) throw new Error('Could not create ' + name); 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; } let loc; try { loc = await officeLocation(true); } catch (e) { return toast(e.message); } if (!loc) return; - const el = openSheet('New ' + label, '', `
+ const el = openSheet(tpl ? 'New from template' : 'New ' + label, tpl ? label : '', `
`); const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); }; @@ -221,7 +221,7 @@ async function newOfficeDoc(x, label) { el.querySelector('#nl').onclick = async () => { // Sheets don't stack, so close this one, choose, then reopen with the new location. 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); 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 btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; 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(); if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener'); diff --git a/public/templates/chore-chart.xlsx b/public/templates/chore-chart.xlsx new file mode 100644 index 0000000..0da1bb6 Binary files /dev/null and b/public/templates/chore-chart.xlsx differ diff --git a/public/templates/expense-tracker.xlsx b/public/templates/expense-tracker.xlsx new file mode 100644 index 0000000..db03b28 Binary files /dev/null and b/public/templates/expense-tracker.xlsx differ diff --git a/public/templates/family-newsletter.docx b/public/templates/family-newsletter.docx new file mode 100644 index 0000000..9f18567 Binary files /dev/null and b/public/templates/family-newsletter.docx differ diff --git a/public/templates/letter.docx b/public/templates/letter.docx new file mode 100644 index 0000000..dff6dc6 Binary files /dev/null and b/public/templates/letter.docx differ diff --git a/public/templates/meal-planner.xlsx b/public/templates/meal-planner.xlsx new file mode 100644 index 0000000..fb11892 Binary files /dev/null and b/public/templates/meal-planner.xlsx differ diff --git a/public/templates/meeting-notes.docx b/public/templates/meeting-notes.docx new file mode 100644 index 0000000..59bca08 Binary files /dev/null and b/public/templates/meeting-notes.docx differ diff --git a/public/templates/monthly-budget.xlsx b/public/templates/monthly-budget.xlsx new file mode 100644 index 0000000..b74d9d1 Binary files /dev/null and b/public/templates/monthly-budget.xlsx differ diff --git a/public/templates/recipe-card.docx b/public/templates/recipe-card.docx new file mode 100644 index 0000000..aebd30d Binary files /dev/null and b/public/templates/recipe-card.docx differ diff --git a/public/templates/resume.docx b/public/templates/resume.docx new file mode 100644 index 0000000..fe4edcc Binary files /dev/null and b/public/templates/resume.docx differ diff --git a/public/templates/simple-presentation.pptx b/public/templates/simple-presentation.pptx new file mode 100644 index 0000000..3fde001 Binary files /dev/null and b/public/templates/simple-presentation.pptx differ diff --git a/tools/make-office-templates.py b/tools/make-office-templates.py new file mode 100644 index 0000000..681b63b --- /dev/null +++ b/tools/make-office-templates.py @@ -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)))