Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
32dbda5c78
16 files changed
+202
-11
No files matched your search
+13
-2
@@ -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 = `<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>`;
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -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>`; }
|
||||
|
||||
/** 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 = `<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>
|
||||
<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>'}
|
||||
<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="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('.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: '' }))); };
|
||||
|
||||
+3
-1
@@ -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; }
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
+7
-7
@@ -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, '', `<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>
|
||||
<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); };
|
||||
@@ -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');
|
||||
|
||||
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.
@@ -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)))
|
||||
Reference in new issue
Block a user