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 = `
+ // 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 = `
`;
+ 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 = `
${Object.entries(OFFICE_KINDS).map(([k, o]) => `${icon('plus')} New ${o.one.toLowerCase()} ${o.exts[0].toUpperCase()} `).join('')}
+
Start from a template ${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides ${OFFICE_TEMPLATES.map((t, i) => `${ofIcon('x.' + t[3])}${esc(t[1])} ${esc(t[2])} `).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 ? '' : '
'}
`;
$('#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 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)))