From 77d3f00072e2fd44fbae7bdf4d17718b03e0cefc Mon Sep 17 00:00:00 2001 From: The OC Guy Date: Fri, 23 Oct 2026 21:41:25 +0000 Subject: [PATCH] Warm styleguide homepage, JSON post feed from n8n, daily sync - css/site.css: oat/espresso palette, Fraunces display, rounded surfaces, warm photo grade - index.html: Module of the Month replaced by the blog; tips, blog and community sections render from data/posts.json - js/feed.js: client-side feed + search over one manifest (blog/index.json technique) - scripts/sync_posts.py: daily webhook fetch, image download, dedupe by id and content hash, non-image attachments skipped - data/posts.json: manifest, one published post (Odoo 20 Community review) --- PRODUCT.md | 4 +- ...eels-like-a-genuinely-polished-release.png | Bin 0 -> 43289 bytes ...els-like-a-genuinely-polished-release.html | 61 ++ css/site.css | 670 +++++++++++------- data/posts.json | 18 + index.html | 110 +-- js/feed.js | 151 ++++ scripts/sync_posts.py | 422 +++++++++++ 8 files changed, 1088 insertions(+), 348 deletions(-) create mode 100644 assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png create mode 100644 blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html create mode 100644 data/posts.json create mode 100644 js/feed.js create mode 100644 scripts/sync_posts.py diff --git a/PRODUCT.md b/PRODUCT.md index 3faf999..5957bc0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -42,7 +42,9 @@ ODOO Community is the best kept secret in business software — a full-featured, - Name: **The OC Guy** - Tagline: **ODOO Community Advocate** - Creator: Oliver Arnold / 安雷威 -- Visual identity from existing video assets: dark background (`#0a0a0f`), purple (`#9C27B0`), cyan (`#00E6FF`), glitch/scanline effects, tech-cyberpunk aesthetic +- Visual identity: warm editorial. Oat ground `#F2E9D6`, espresso ink `#3B2619`, warm sand `#D9C7A5`, honey oak `#AC8050`, dusty navy `#29444B`, muted olive `#747552`, terracotta accent `#B76D48`. Chunky rounded display type (Fraunces), soft rounded surfaces, warm window light, matte textures. No stark white, no pure black, no neon, no cyberpunk. +- Style authority: `/root/workspace/shared/manifesto/STYLEGUIDE.md` — read it before changing any page. Claims and voice authority: `MANIFESTO.md` in the same directory. +- The old dark/purple/cyan video identity (`#0a0a0f`, `#9C27B0`, `#00E6FF`) is retired for web. Video title cards still needing it should be re-rendered in the warm palette rather than reintroducing the old one. - Domain: `oc-guy.com` ## Evidence on Hand diff --git a/assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png b/assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d98808ad217af353fe237c3b5a1fd4721907e2 GIT binary patch literal 43289 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 literal 0 HcmV?d00001 diff --git a/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html b/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html new file mode 100644 index 0000000..ad0c639 --- /dev/null +++ b/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html @@ -0,0 +1,61 @@ + + + + + +Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release — The OC Guy + + + + + + + + + +
+ The OC Guy + +
+
+ +

Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release

+ +

Odoo 20.0 Community is available, and after spending time testing it, I can say it is a genuinely polished release. That is not a phrase I throw around lightly when a new version lands.

+ +

My usual focus is the business side of a release — the changes that quietly affect how you work every day. Design rarely tops my list. But the small design updates teased before launch are a real step forward. They are not flashy for the sake of it; they make the interface feel calmer and more considered.

+ +

The AI and MCP Side

+ +

I cannot brag about the AI and MCP-related features the way my friend Andrew from Odoo It Yourself has in his review. It is well worth watching: Odoo It Yourself review.

+ +

For my own work, though, that gap matters less than you might expect. My approach has always been to treat Odoo as a stable foundation and pimp it with tools like n8n and a Hermes Agent. That setup does not depend on AI features shipping inside the core. It also gives me something better than a feature list — a reason to write about how to fill the gap yourself.

+ +

What Is Coming Next

+ +

Stay tuned for Odoo Community-related AI posts. There is plenty to explore, and the absence of built-in AI in Community makes the DIY angle more interesting, not less.

+ +

And with Release 20, I am hoping to finally write the blog post I have wanted to write for a long time: how to use Community for accounting.

+ +

Take a look at the release, test it against your own setup, and see what it changes for you. When you are ready to go further, check out our homepage.

+

Published 23 Oct 2026 · Odoo Community news by The OC Guy. + Conditions under which this advice fails are stated in the text; if they are missing, the post is not finished.

+
+
+ +
+

Odoo Community advocate. Written by Oliver Arnold, 安雷威 — no partner programme, no licences sold.

+ +
+
+ + diff --git a/css/site.css b/css/site.css index 97afaeb..ff98292 100644 --- a/css/site.css +++ b/css/site.css @@ -1,30 +1,45 @@ /* ============================================================ - The OC Guy — editorial rebuild - Design language from the VITA reference: paper ground, black - ink, hairline rules, didone serif display, tiny tracked caps - for labels, italic serif meta, boxed modules, 3-column cards. + The OC Guy — warm editorial rebuild + Style authority: /root/workspace/shared/manifesto/STYLEGUIDE.md + Palette: oat cream ground, espresso ink, honey oak and sand, + restrained navy + olive accents. Soft rounded surfaces, + chunky rounded display type (Fraunces), generous negative space. + No stark white, no pure black, no cool blue cast. ============================================================ */ :root { - --paper: #fbfaf7; - --ink: #111110; - --muted: #6c6a66; - --rule: rgba(17, 17, 16, 0.16); - --rule-strong: rgba(17, 17, 16, 0.42); - --tint: #f2f0ec; + /* palette — from STYLEGUIDE.md §2 */ + --oat: #f2e9d6; + --sand: #d9c7a5; + --oak: #ac8050; + --espresso: #3b2619; + --navy: #29444b; + --olive: #747552; + --terracotta: #b76d48; - --serif: "Playfair Display", "Times New Roman", Georgia, serif; + --paper: var(--oat); + --ink: var(--espresso); + --muted: #6f5641; + --rule: rgba(59, 38, 25, 0.16); + --rule-strong: rgba(59, 38, 25, 0.38); + --tint: rgba(217, 199, 165, 0.52); + + --display: "Fraunces", "Iowan Old Style", Georgia, serif; --sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; - --pad: clamp(16px, 3.2vw, 40px); + --pad: clamp(18px, 3.4vw, 44px); --max: 1180px; --gap: clamp(24px, 3vw, 44px); + --r-img: 14px; + --r-box: 20px; + --r-btn: 999px; + --step-xs: 11px; --step-sm: 13px; --step-body: 16px; --step-lead: clamp(17px, 1.1vw, 19px); - --step-h3: clamp(19px, 1.5vw, 23px); + --step-h3: clamp(20px, 1.6vw, 24px); --step-h2: clamp(30px, 3.4vw, 46px); --step-display: clamp(40px, 6.4vw, 92px); } @@ -39,15 +54,30 @@ body { color: var(--ink); font-family: var(--sans); font-size: var(--step-body); - line-height: 1.55; + line-height: 1.6; + -webkit-font-smoothing: antialiased; } +/* a very soft warm wash, so the page is never flat cream */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient(60% 42% at 78% 0%, rgba(172, 128, 80, 0.14), transparent 70%), + radial-gradient(46% 36% at 4% 18%, rgba(217, 199, 165, 0.55), transparent 72%); +} + +body > * { position: relative; z-index: 1; } + img { display: block; max-width: 100%; height: auto; } a { color: inherit; } button, input { font: inherit; color: inherit; } -:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } -::selection { background: var(--ink); color: var(--paper); } +:focus-visible { outline: 2px solid var(--navy); outline-offset: 3px; } +::selection { background: var(--espresso); color: var(--oat); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; @@ -56,7 +86,8 @@ button, input { font: inherit; color: inherit; } .skip { position: fixed; top: 8px; left: 8px; z-index: 60; - padding: 10px 16px; background: var(--ink); color: var(--paper); + padding: 10px 18px; background: var(--espresso); color: var(--oat); + border-radius: var(--r-btn); font-size: var(--step-sm); text-decoration: none; transform: translateY(-160%); transition: transform 220ms ease; } @@ -67,7 +98,7 @@ button, input { font: inherit; color: inherit; } .masthead { max-width: var(--max); margin: 0 auto; - padding: clamp(20px, 3vw, 34px) var(--pad) 18px; + padding: clamp(22px, 3vw, 36px) var(--pad) 18px; display: grid; grid-template-columns: 1fr auto; align-items: end; @@ -76,13 +107,15 @@ button, input { font: inherit; color: inherit; } } .wordmark { - font-family: var(--serif); - font-weight: 700; - font-size: clamp(40px, 6.2vw, 76px); + font-family: var(--display); + font-weight: 600; + font-size: clamp(38px, 5.8vw, 72px); + font-variation-settings: "SOFT" 80, "WONK" 1; letter-spacing: -0.035em; - line-height: 0.86; + line-height: 0.88; text-transform: uppercase; text-decoration: none; + color: var(--espresso); } .nav { @@ -92,26 +125,30 @@ button, input { font: inherit; color: inherit; } gap: clamp(12px, 1.8vw, 26px); font-size: var(--step-xs); font-weight: 500; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; + color: var(--muted); } -.nav a { text-decoration: none; padding: 6px 0; } -.nav a:hover { text-decoration: underline; text-underline-offset: 5px; } +.nav a { text-decoration: none; padding: 6px 0; color: var(--espresso); } +.nav a:hover { color: var(--navy); text-decoration: underline; text-underline-offset: 6px; text-decoration-thickness: 1.5px; } .nav-cta { grid-column: 2; justify-self: end; margin-top: 10px; - padding: 9px 18px; - border: 1px solid var(--ink); + padding: 10px 20px; + background: var(--espresso); + color: var(--oat); + border: 1px solid var(--espresso); + border-radius: var(--r-btn); font-size: var(--step-xs); font-weight: 600; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none; - transition: background-color 160ms ease, color 160ms ease; + transition: background-color 180ms ease, color 180ms ease, transform 180ms ease; } -.nav-cta:hover, .nav-cta:focus-visible { background: var(--ink); color: var(--paper); } +.nav-cta:hover, .nav-cta:focus-visible { background: var(--oak); border-color: var(--oak); color: var(--espresso); transform: translateY(-1px); } /* ---------- lead ---------- */ @@ -125,14 +162,16 @@ button, input { font: inherit; color: inherit; } "side media" "side caption"; column-gap: var(--gap); - row-gap: 22px; + row-gap: 24px; } .lead-side { grid-area: side; display: flex; flex-direction: column; gap: clamp(22px, 3vw, 36px); } .box { border: 1px solid var(--rule-strong); - padding: 22px 20px 20px; + border-radius: var(--r-box); + padding: 24px 22px 22px; + background: rgba(255, 250, 240, 0.42); } .box-kicker { margin: 0 0 12px; @@ -140,34 +179,35 @@ button, input { font: inherit; color: inherit; } font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--oak); } .box-lede { - margin: 0 0 18px; - font-family: var(--serif); - font-style: italic; + margin: 0 0 20px; + font-family: var(--display); font-size: var(--step-lead); line-height: 1.45; + color: var(--espresso); } .btn { display: inline-flex; align-items: center; justify-content: center; - min-height: 44px; - padding: 0 22px; - background: var(--ink); - color: var(--paper); - border: 1px solid var(--ink); - border-radius: 0; + min-height: 46px; + padding: 0 24px; + background: var(--espresso); + color: var(--oat); + border: 1px solid var(--espresso); + border-radius: var(--r-btn); font-size: var(--step-xs); font-weight: 600; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none; cursor: pointer; - transition: background-color 160ms ease, color 160ms ease; + transition: background-color 180ms ease, color 180ms ease, transform 180ms ease; } -.btn:hover, .btn:focus-visible { background: transparent; color: var(--ink); } +.btn:hover, .btn:focus-visible { background: transparent; color: var(--espresso); transform: translateY(-1px); } .side-list { margin: 0; @@ -175,9 +215,9 @@ button, input { font: inherit; color: inherit; } list-style: none; border-top: 1px solid var(--rule); font-size: var(--step-sm); - line-height: 1.5; + line-height: 1.55; } -.side-list li { padding: 12px 0; border-bottom: 1px solid var(--rule); } +.side-list li { padding: 13px 0; border-bottom: 1px solid var(--rule); } .side-kicker { display: block; font-size: var(--step-xs); @@ -186,41 +226,57 @@ button, input { font: inherit; color: inherit; } color: var(--muted); margin-bottom: 3px; } -.side-list a { text-decoration: underline; text-underline-offset: 3px; } +.side-list a { text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--oak); } .lead-media { grid-area: media; } -.lead-media img { width: 100%; aspect-ratio: 16 / 11; object-fit: cover; object-position: 50% 42%; } +.lead-media img { + width: 100%; + aspect-ratio: 16 / 11; + object-fit: cover; + object-position: 50% 42%; + border-radius: var(--r-img); + /* warm grade: pulls the cool studio grey of stock photography back into the palette */ + filter: sepia(0.34) saturate(0.78) contrast(1.04) brightness(1.02) hue-rotate(-6deg); +} -.lead-caption { grid-area: caption; max-width: 36em; } +.lead-caption { grid-area: caption; max-width: 38em; } .meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 14px; - margin: 0 0 12px; + margin: 0 0 14px; font-size: var(--step-xs); - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); } -.meta span:first-child { color: var(--ink); font-weight: 600; } +.meta span:first-child { + color: var(--espresso); + font-weight: 600; + background: var(--sand); + border-radius: var(--r-btn); + padding: 3px 10px; +} h1 { - margin: 0 0 12px; - font-family: var(--serif); - font-weight: 500; - font-size: clamp(26px, 3.1vw, 42px); - line-height: 1.1; - letter-spacing: -0.015em; + margin: 0 0 14px; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 70, "WONK" 1; + font-size: clamp(30px, 3.6vw, 50px); + line-height: 1.06; + letter-spacing: -0.025em; + text-wrap: balance; } .standfirst { margin: 0; - font-family: var(--serif); font-size: var(--step-lead); - line-height: 1.55; - color: #2a2926; + line-height: 1.6; + color: var(--muted); + max-width: 34em; } /* ---------- section frame ---------- */ @@ -228,7 +284,7 @@ h1 { .section { max-width: var(--max); margin: 0 auto; - padding: clamp(34px, 4.5vw, 56px) var(--pad) 0; + padding: clamp(34px, 4.5vw, 58px) var(--pad) 0; } .section-head { @@ -237,15 +293,17 @@ h1 { align-items: baseline; column-gap: 16px; padding-bottom: 14px; - border-bottom: 1px solid var(--ink); + border-bottom: 1px solid var(--rule-strong); margin-bottom: clamp(22px, 3vw, 36px); } .initial { - font-family: var(--serif); - font-size: clamp(54px, 7.4vw, 96px); - line-height: 0.7; - font-weight: 400; + font-family: var(--display); + font-size: clamp(50px, 6.6vw, 84px); + line-height: 0.72; + font-weight: 600; + font-variation-settings: "SOFT" 90; + color: var(--oak); } .section-title { @@ -255,204 +313,198 @@ h1 { font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--muted); } .section-title em { - font-family: var(--serif); - font-style: italic; + font-family: var(--display); + font-style: normal; font-size: var(--step-h3); - font-weight: 400; - letter-spacing: 0; + font-weight: 600; + font-variation-settings: "SOFT" 70; + letter-spacing: -0.01em; text-transform: none; margin-left: 8px; + color: var(--espresso); } .more { font-size: var(--step-xs); font-weight: 600; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none; - border-bottom: 1px solid var(--ink); + border-bottom: 1px solid var(--oak); padding-bottom: 2px; justify-self: end; + color: var(--espresso); } -.more:hover, .more:focus-visible { background: var(--ink); color: var(--paper); } +.more:hover, .more:focus-visible { color: var(--navy); border-color: var(--navy); } /* ---------- cards ---------- */ .cards { display: grid; grid-template-columns: repeat(3, 1fr); - border-top: 1px solid var(--rule); + gap: clamp(14px, 1.6vw, 22px); } .card { display: flex; flex-direction: column; - gap: 10px; - padding: 20px clamp(12px, 1.8vw, 24px) 22px; + gap: 12px; + padding: 16px 16px 20px; text-decoration: none; - border-bottom: 1px solid var(--rule); - transition: background-color 200ms ease; + border: 1px solid var(--rule); + border-radius: var(--r-box); + background: rgba(255, 250, 240, 0.38); + transition: background-color 220ms ease, transform 220ms ease, border-color 220ms ease; +} +.card:hover, .card:focus-visible { + background: var(--tint); + border-color: var(--rule-strong); + transform: translateY(-3px); } -.card + .card { border-left: 1px solid var(--rule); } -.card:hover, .card:focus-visible { background: var(--tint); } .card-img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; object-position: 50% 45%; - background: var(--tint); + border-radius: var(--r-img); + background: var(--sand); + filter: sepia(0.3) saturate(0.82) contrast(1.04) brightness(1.02) hue-rotate(-6deg); } .card-cat { - display: block; - padding: 7px 0; - border-top: 1px solid var(--rule); - border-bottom: 1px solid var(--rule); - text-align: center; + display: inline-block; + align-self: flex-start; + padding: 4px 12px; + border-radius: var(--r-btn); + background: var(--navy); + color: var(--oat); font-size: var(--step-xs); font-weight: 600; - letter-spacing: 0.16em; + letter-spacing: 0.14em; text-transform: uppercase; } .card h3 { - margin: 2px 0 0; - font-family: var(--serif); - font-weight: 500; + margin: 0; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 60; font-size: var(--step-h3); - line-height: 1.25; - text-align: center; + line-height: 1.2; + letter-spacing: -0.01em; } .card-desc { margin: 0; font-size: var(--step-sm); - line-height: 1.5; + line-height: 1.55; color: var(--muted); - text-align: center; } .card-meta { margin-top: auto; - padding-top: 6px; - font-family: var(--serif); - font-style: italic; - font-size: var(--step-sm); - color: var(--muted); - text-align: center; + padding-top: 4px; + font-size: var(--step-xs); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--oak); } -.card:hover h3, .card:focus-visible h3 { text-decoration: underline; text-underline-offset: 4px; } +.card:hover h3, .card:focus-visible h3 { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1.5px; } -/* ---------- featured module ---------- */ +/* ---------- featured module (espresso spread) ---------- */ .feature { + background: var(--espresso); + color: #f6ecda; + border: 0; + border-radius: var(--r-box); display: grid; - grid-template-columns: 4fr 7fr; - border: 1px solid var(--ink); + grid-template-columns: 5fr 7fr; + align-items: stretch; + overflow: hidden; } -.feature-plate { - padding: clamp(20px, 2.4vw, 30px); - border-right: 1px solid var(--ink); - background: var(--tint); +.feature-photo { margin: 0; } +.feature-photo img { + width: 100%; + height: 100%; + min-height: 420px; + object-fit: cover; + object-position: 50% 45%; + filter: sepia(0.32) saturate(0.8) contrast(1.05) brightness(0.98) hue-rotate(-6deg); } +.feature-body { padding: clamp(24px, 3vw, 40px); } + .plate-kicker { margin: 0 0 18px; font-size: var(--step-xs); font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; - color: var(--muted); + color: rgba(242, 233, 214, 0.62); } -.plate-name { - margin: 0; - font-family: var(--serif); - font-size: clamp(34px, 4vw, 56px); - line-height: 0.95; - letter-spacing: -0.02em; -} - -.plate-facts { - margin: 22px 0 0; - padding: 0; - list-style: none; - border-top: 1px solid var(--rule-strong); - font-size: var(--step-sm); -} -.plate-facts li { - display: flex; - justify-content: space-between; - gap: 12px; - padding: 9px 0; - border-bottom: 1px solid var(--rule); -} -.plate-facts span { - font-size: var(--step-xs); - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--muted); -} - -.plate-note { - margin: 18px 0 0; - font-family: var(--serif); - font-style: italic; - font-size: var(--step-sm); - line-height: 1.45; - color: var(--muted); -} - -.feature-body { padding: clamp(22px, 2.8vw, 36px); } - .feature-name { - margin: 0 0 12px; - font-family: var(--serif); - font-weight: 500; - font-size: clamp(24px, 2.6vw, 34px); - line-height: 1.15; + margin: 0 0 14px; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 70; + font-size: clamp(26px, 2.8vw, 38px); + line-height: 1.12; + letter-spacing: -0.02em; + color: #fbf5e8; } -.feature-body > p { margin: 0 0 20px; max-width: 40em; } +.feature-body > p { margin: 0 0 22px; max-width: 40em; color: rgba(246, 236, 218, 0.84); } .benefits { - margin: 0 0 24px; + margin: 0 0 26px; padding: 0; list-style: none; - border-top: 1px solid var(--rule); + border-top: 1px solid rgba(242, 233, 214, 0.24); } .benefits li { - padding: 12px 0; - border-bottom: 1px solid var(--rule); + padding: 13px 0; + border-bottom: 1px solid rgba(242, 233, 214, 0.16); font-size: var(--step-sm); - line-height: 1.5; - color: var(--muted); + line-height: 1.55; + color: rgba(246, 236, 218, 0.7); } .benefits strong { display: block; margin-bottom: 2px; - font-family: var(--serif); + font-family: var(--display); font-size: var(--step-body); - font-weight: 500; - color: var(--ink); + font-weight: 600; + font-variation-settings: "SOFT" 60; + color: #fbf5e8; } .feature-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; } .feature-note { - font-family: var(--serif); - font-style: italic; font-size: var(--step-sm); - color: var(--muted); + color: rgba(246, 236, 218, 0.64); } +.feature .btn { background: var(--sand); border-color: var(--sand); color: var(--espresso); } +.feature .btn:hover, .feature .btn:focus-visible { background: transparent; color: var(--sand); } + +/* legacy plate block, kept for reuse */ +.feature-plate { padding: clamp(20px, 2.4vw, 30px); border-right: 1px solid rgba(242, 233, 214, 0.24); background: rgba(242, 233, 214, 0.06); } +.plate-name { margin: 0; font-family: var(--display); font-weight: 600; font-size: clamp(34px, 4vw, 56px); line-height: 0.95; letter-spacing: -0.02em; } +.plate-facts { margin: 22px 0 0; padding: 0; list-style: none; border-top: 1px solid rgba(242, 233, 214, 0.24); font-size: var(--step-sm); } +.plate-facts li { display: flex; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid rgba(242, 233, 214, 0.16); } +.plate-facts span { font-size: var(--step-xs); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(242, 233, 214, 0.58); } +.plate-note { margin: 18px 0 0; font-size: var(--step-sm); line-height: 1.45; color: rgba(242, 233, 214, 0.62); } /* ---------- community news ---------- */ -.news { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--rule); } +.news { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--rule-strong); } .news li { display: grid; @@ -463,31 +515,32 @@ h1 { } .news-date { - font-family: var(--serif); - font-style: italic; - font-size: var(--step-sm); - color: var(--muted); - padding-top: 4px; -} - -.news-body h3 { - margin: 0 0 6px; - font-family: var(--serif); - font-weight: 500; - font-size: var(--step-h3); - line-height: 1.2; -} -.news-body h3 a { text-decoration: none; } -.news-body h3 a:hover { text-decoration: underline; text-underline-offset: 4px; } -.news-body p { margin: 0; font-size: var(--step-sm); line-height: 1.5; color: var(--muted); max-width: 52em; } - -.news-source { font-size: var(--step-xs); letter-spacing: 0.12em; text-transform: uppercase; - color: var(--muted); - text-align: right; + color: var(--oak); padding-top: 6px; +} + +.news-body h3 { + margin: 0 0 8px; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 60; + font-size: var(--step-h3); + line-height: 1.2; + letter-spacing: -0.01em; +} +.news-body h3 a { text-decoration: none; } +.news-body h3 a:hover { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 1.5px; } +.news-body p { margin: 0; font-size: var(--step-sm); line-height: 1.55; color: var(--muted); max-width: 52em; } + +.news-source { + font-size: var(--step-xs); + letter-spacing: 0.1em; + color: var(--olive); + text-align: right; + padding-top: 8px; overflow-wrap: anywhere; } @@ -495,26 +548,28 @@ h1 { .ask { max-width: var(--max); - margin: clamp(40px, 6vw, 72px) auto 0; + margin: clamp(40px, 6vw, 76px) auto 0; padding: 0 var(--pad); } .ask-inner { - border-top: 1px solid var(--ink); - border-bottom: 1px solid var(--ink); - padding: clamp(26px, 3vw, 40px) 0; + background: var(--sand); + border-radius: var(--r-box); + padding: clamp(26px, 3vw, 42px); display: grid; grid-template-columns: 3fr 7fr; gap: var(--gap); - align-items: start; + align-items: center; } .ask h2 { margin: 0; - font-family: var(--serif); - font-weight: 500; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 80, "WONK" 1; font-size: var(--step-h2); - line-height: 1.05; + line-height: 1.04; + letter-spacing: -0.025em; } -.ask-lede { margin: 8px 0 0; font-family: var(--serif); font-style: italic; color: var(--muted); } +.ask-lede { margin: 8px 0 0; font-size: var(--step-sm); color: #5d4632; } .ask-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .ask-form label { @@ -522,37 +577,41 @@ h1 { font-size: var(--step-xs); letter-spacing: 0.16em; text-transform: uppercase; - color: var(--muted); + color: #5d4632; } .ask-form input { flex: 1 1 240px; - min-height: 44px; - padding: 0 14px; - background: transparent; - border: 1px solid var(--rule-strong); - border-radius: 0; + min-height: 48px; + padding: 0 18px; + background: rgba(255, 250, 240, 0.72); + border: 1px solid rgba(59, 38, 25, 0.24); + border-radius: var(--r-btn); font-size: var(--step-body); } -.ask-form input:focus-visible { border-color: var(--ink); } +.ask-form input::placeholder { color: rgba(59, 38, 25, 0.5); } +.ask-form input:focus-visible { border-color: var(--espresso); outline-color: var(--espresso); } /* ---------- footer ---------- */ .foot { - margin-top: clamp(48px, 7vw, 88px); - background: var(--ink); - color: #f4f2ee; - padding: clamp(32px, 4vw, 52px) var(--pad) 24px; + margin-top: clamp(48px, 7vw, 92px); + background: var(--espresso); + color: var(--oat); + border-radius: 26px 26px 0 0; + padding: clamp(32px, 4vw, 54px) var(--pad) 26px; } .foot-name { max-width: var(--max); margin: 0 auto 26px; - font-family: var(--serif); - font-weight: 700; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 90, "WONK" 1; font-size: clamp(44px, 9vw, 116px); - line-height: 0.86; - letter-spacing: -0.035em; + line-height: 0.84; + letter-spacing: -0.04em; text-transform: uppercase; + color: #fbf5e8; } .foot-cols { @@ -561,24 +620,24 @@ h1 { display: grid; grid-template-columns: 1fr auto; gap: 24px; - padding-top: 20px; - border-top: 1px solid rgba(255, 255, 255, 0.25); + padding-top: 22px; + border-top: 1px solid rgba(242, 233, 214, 0.24); } -.foot-note { margin: 0; max-width: 34em; font-family: var(--serif); font-style: italic; font-size: var(--step-sm); line-height: 1.5; } +.foot-note { margin: 0; max-width: 34em; font-size: var(--step-sm); line-height: 1.6; color: rgba(246, 236, 218, 0.78); } -.foot-links { display: flex; flex-wrap: wrap; gap: 18px; font-size: var(--step-xs); letter-spacing: 0.16em; text-transform: uppercase; } -.foot-links a { text-decoration: none; border-bottom: 1px solid rgba(255, 255, 255, 0.4); padding-bottom: 2px; } -.foot-links a:hover { border-color: #fff; } +.foot-links { display: flex; flex-wrap: wrap; gap: 18px; font-size: var(--step-xs); letter-spacing: 0.14em; text-transform: uppercase; } +.foot-links a { text-decoration: none; border-bottom: 1px solid rgba(242, 233, 214, 0.4); padding-bottom: 2px; } +.foot-links a:hover { border-color: var(--sand); color: var(--sand); } .foot-credits { max-width: var(--max); - margin: 22px auto 0; + margin: 24px auto 0; font-size: var(--step-xs); - line-height: 1.6; - color: rgba(255, 255, 255, 0.62); + line-height: 1.65; + color: rgba(242, 233, 214, 0.58); } -.foot-credits a { color: rgba(255, 255, 255, 0.82); } +.foot-credits a { color: rgba(242, 233, 214, 0.8); } /* ---------- motion ---------- */ @@ -592,6 +651,7 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } html.js [data-rv] { opacity: 1; transform: none; transition: none; } + .card:hover, .card:focus-visible, .btn:hover, .nav-cta:hover { transform: none; } } /* ---------- tablet ---------- */ @@ -606,12 +666,12 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } .side-list { flex: 1 1 260px; } .feature { grid-template-columns: 1fr; } - .feature-plate { border-right: 0; border-bottom: 1px solid var(--ink); } + .feature-photo img { min-height: 0; height: clamp(200px, 38vw, 300px); } .news li { grid-template-columns: 96px 1fr; } .news-source { grid-column: 2; text-align: left; padding-top: 0; } - .ask-inner { grid-template-columns: 1fr; } + .ask-inner { grid-template-columns: 1fr; align-items: start; } } /* ---------- phone ---------- */ @@ -624,8 +684,7 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } .lead-side { flex-direction: column; } .cards { grid-template-columns: 1fr; } - .card { padding: 18px 0 22px; } - .card + .card { border-left: 0; } + .card { padding: 14px 14px 18px; } .card-img { aspect-ratio: 16 / 9; } .section-head { grid-template-columns: auto 1fr; row-gap: 10px; } @@ -638,6 +697,7 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } .ask-form .btn { width: 100%; } .foot-cols { grid-template-columns: 1fr; } + .foot { border-radius: 20px 20px 0 0; } } /* ---------- touch targets on phones ---------- */ @@ -655,75 +715,59 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } .foot-links { gap: 10px 18px; } } -/* ---------- featured module as a dark spread ---------- */ -.feature { - background: var(--ink); - color: #f4f2ee; - border: 0; - grid-template-columns: 5fr 7fr; - align-items: stretch; -} -.feature-photo { margin: 0; } -.feature-photo img { width: 100%; height: 100%; min-height: 420px; object-fit: cover; object-position: 50% 45%; } -.feature-body p { color: rgba(255, 255, 255, 0.82); } -.plate-kicker { color: rgba(255, 255, 255, 0.58); } -.feature-name { color: #fff; } -.benefits { border-top-color: rgba(255, 255, 255, 0.25); } -.benefits li { border-bottom-color: rgba(255, 255, 255, 0.16); color: rgba(255, 255, 255, 0.66); } -.benefits strong { color: #fff; } -.feature-note { color: rgba(255, 255, 255, 0.6); } -.feature .btn { background: #f4f2ee; border-color: #f4f2ee; color: var(--ink); } -.feature .btn:hover, .feature .btn:focus-visible { background: transparent; color: #f4f2ee; } - -@media (max-width: 900px) { - .feature { grid-template-columns: 1fr; } - .feature-photo img { min-height: 0; height: clamp(200px, 38vw, 300px); } -} - /* ---------- OCA module status table ---------- */ -.oca-table { width: 100%; border-collapse: collapse; font-size: var(--step-sm); } + +.oca-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: var(--step-sm); +} .oca-table thead th { - padding: 0 0 10px; + padding: 0 0 12px; text-align: left; font-size: var(--step-xs); font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted); - border-bottom: 1px solid var(--ink); + border-bottom: 1px solid var(--rule-strong); } .oca-table thead th:nth-child(1) { width: 54%; } .oca-table thead th:nth-child(2) { width: 26%; } .oca-table thead th:nth-child(3), .oca-table thead th:nth-child(4) { width: 74px; text-align: center; } -.oca-table tbody td { padding: 13px 0; border-bottom: 1px solid var(--rule); vertical-align: middle; } +.oca-table tbody td { padding: 14px 0; border-bottom: 1px solid var(--rule); vertical-align: middle; } .oca-name a { - font-family: var(--serif); + font-family: var(--display); font-size: var(--step-h3); - font-weight: 500; + font-weight: 600; + font-variation-settings: "SOFT" 60; line-height: 1.2; + letter-spacing: -0.01em; text-decoration: none; } -.oca-name a:hover, .oca-name a:focus-visible { text-decoration: underline; text-underline-offset: 4px; } +.oca-name a:hover, .oca-name a:focus-visible { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1.5px; } .oca-area { font-size: var(--step-xs); - letter-spacing: 0.14em; + letter-spacing: 0.12em; text-transform: uppercase; - color: var(--muted); + color: var(--olive); } .oca-status { text-align: center; } .mark { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 24px; + height: 24px; border: 1px solid var(--rule-strong); + border-radius: var(--r-btn); font-size: 12px; line-height: 1; color: var(--muted); } -.mark.is-on { background: var(--ink); border-color: var(--ink); color: var(--paper); } +.mark.is-on { background: var(--olive); border-color: var(--olive); color: var(--oat); } @media (max-width: 600px) { .oca-table thead th:nth-child(2), @@ -731,3 +775,99 @@ html.js [data-rv].rv-in { opacity: 1; transform: none; } .oca-name a { font-size: var(--step-body); } .oca-table tbody td { padding: 12px 0; } } + +/* ---------- feed tooling (posts.json driven) ---------- */ + +.feed-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin-bottom: 18px; +} +.feed-search { + flex: 1 1 300px; + min-height: 46px; + padding: 0 20px; + background: rgba(255, 250, 240, 0.6); + border: 1px solid var(--rule-strong); + border-radius: var(--r-btn); + font-size: var(--step-body); + color: var(--espresso); +} +.feed-search::placeholder { color: rgba(59, 38, 25, 0.5); } +.feed-search:focus-visible { border-color: var(--espresso); outline-color: var(--espresso); } + +.feed-count { + font-size: var(--step-xs); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; +} + +.feed-empty { + margin: 0; + padding: 22px 20px; + border: 1px dashed var(--rule-strong); + border-radius: var(--r-box); + font-size: var(--step-sm); + color: var(--muted); +} + +/* ---------- single post page ---------- */ + +.post { padding-bottom: clamp(40px, 6vw, 72px); } +.post-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 0 0 18px; + font-size: var(--step-xs); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} +.post-title { + max-width: 22em; + margin: 0 0 26px; + font-family: var(--display); + font-weight: 600; + font-variation-settings: "SOFT" 70, "WONK" 1; + font-size: clamp(32px, 4vw, 56px); + line-height: 1.05; + letter-spacing: -0.03em; + text-wrap: balance; +} +.post-image { + width: 100%; + border-radius: var(--r-img); + margin-bottom: 28px; + filter: sepia(0.18) saturate(0.9) contrast(1.03); +} +.post-body { max-width: 40em; font-size: var(--step-lead); line-height: 1.68; } +.post-body h2, .post-body h3 { + font-family: var(--display); + font-variation-settings: "SOFT" 60; + letter-spacing: -0.02em; + margin: 1.6em 0 0.5em; +} +.post-body p { margin: 0 0 1.1em; } +.post-body img { border-radius: var(--r-img); margin: 1.4em 0; } +.post-body a { text-decoration-color: var(--oak); text-underline-offset: 3px; } +.post-body code, .post-body pre { + background: rgba(59, 38, 25, 0.08); + border-radius: 8px; + padding: 0.1em 0.35em; + font-size: 0.9em; +} +.post-body pre { padding: 16px 18px; overflow-x: auto; } +.post-note { + max-width: 40em; + margin: 40px 0 0; + padding-top: 16px; + border-top: 1px solid var(--rule); + font-size: var(--step-sm); + color: var(--muted); +} diff --git a/data/posts.json b/data/posts.json new file mode 100644 index 0000000..86a9d73 --- /dev/null +++ b/data/posts.json @@ -0,0 +1,18 @@ +{ + "generated": "2026-10-23T21:40:57+00:00", + "source": "https://n8n.odoo4projects.com/webhook/oc-guy/posts", + "posts": [ + { + "id": "odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release", + "source_id": "17", + "date": "2026-10-23", + "category": "news", + "headline": "Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release", + "teaser": "Odoo 20.0 Community is available, and after spending time testing it, I can say it is a genuinely polished release. That is not a phrase I throw around lightly when a new version lands. My usual…", + "image": "assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png", + "link": "blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html", + "hash": "ac409d15d7dd3159", + "published": "2026-10-23T21:40:57+00:00" + } + ] +} diff --git a/index.html b/index.html index ec2f658..68fb48d 100644 --- a/index.html +++ b/index.html @@ -6,9 +6,10 @@ The OC Guy — Odoo Community tips, modules and community news + - + @@ -20,7 +21,7 @@ @@ -60,66 +61,32 @@

The latest in Tips & Tricks

- View all tips + All posts
-
- - Two plain cardboard boxes stacked on a wooden table - Install -

Run Odoo from Docker Compose, not from source

-

Two containers, one file, a volume you can copy. The shortest path to a server you - understand and can move next year without a migration project.

- 6 min read -
- - - Rows of old book spines lined up on a shelf - Backup -

Dump the database nightly and restore it monthly

-

A backup you have never restored is a hope, not a backup. One pg_dump, one cron line, - and a restore into a scratch copy that takes four minutes.

- 4 min read -
- - - Hand tools, a clamp and a hammer on a workbench - Extend -

Take an OCA module before you write your own

-

Someone already solved your problem and maintains it for every series. Reading their - code teaches you the ORM faster than any tutorial.

- 8 min read -
+
+ + + 0 posts
+ +
-
+
- -

Featured Module of the month

+ +

Everything The OC Guy blog

+ Community news
-
-
- Steel shelving in a warehouse, stock and pallets stacked to the ceiling -
- -
-

Odoo Community · LGPL-3 · series 18.0

-

Inventory, before you buy anything

-

One module holds what you own, where it sits and what it is worth: stock moves, reordering rules, - lots and serial numbers, three warehouse steps if you need them, one if you do not.

-
    -
  • Real stock, not a spreadsheetEvery receipt, delivery and adjustment is a move with a date and a person behind it.
  • -
  • Barcode-ready on day oneThe Community module ships the operations your scanner needs; no paid add-on.
  • -
  • Multi-warehouse without a second systemLocations, routes and reordering rules live in the same database as your invoices.
  • -
-
- Explore module - Free in the Community edition · installs from Apps -
-
+
+ + + 0 posts
+ +
@@ -194,35 +161,13 @@ View all community news -
    -
  • - -
    -

    RMA: returns, repairs and replacements inside Odoo

    -

    Return Merchandise Authorization for the whole flow — the customer sends it back, you decide repair - or credit, the stock move happens once.

    -
    - github.com/OCA/rma -
  • -
  • - -
    -

    Helpdesk modules that stay out of your way

    -

    Tickets, teams and SLAs without the enterprise edition: the OCA helpdesk repository keeps a branch - per supported series, so an upgrade does not orphan your support desk.

    -
    - github.com/OCA/helpdesk -
  • -
  • - -
    -

    Localisation is community work: Italy's charts, kept current

    -

    Tax reports, e-invoicing and the country chart of accounts, maintained by the people who file with - them. The same pattern exists for your country.

    -
    - github.com/OCA/l10n-italy -
  • -
+
+ + + 0 posts +
+ +
    @@ -258,5 +203,6 @@ + diff --git a/js/feed.js b/js/feed.js new file mode 100644 index 0000000..11f5558 --- /dev/null +++ b/js/feed.js @@ -0,0 +1,151 @@ +/* The OC Guy — post feed. + One manifest (data/posts.json) written by scripts/sync_posts.py, three views: + Tips & Tricks, Community news and the Blog. Search is client-side over + headline + teaser + category, the same technique as blog/index.json on + odoo4projects.com. No JS, no feed: the sections say so instead of lying. */ +(function () { + 'use strict'; + + var SOURCE = 'data/posts.json'; + var BADGE = { tips: 'Tips & Tricks', news: 'Community news', blog: 'Blog' }; + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + } + + function fmtDate(iso) { + var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + var d = new Date(iso + 'T00:00:00Z'); + if (isNaN(d.getTime())) return iso || ''; + return d.getUTCDate() + ' ' + months[d.getUTCMonth()] + ' ' + d.getUTCFullYear(); + } + + function matches(p, term) { + if (!term) return true; + var hay = (p.headline + ' ' + p.teaser + ' ' + p.category + ' ' + (p.tags || []).join(' ')).toLowerCase(); + return hay.indexOf(term) !== -1; + } + + function emptyState(node, kind) { + node.textContent = ''; + var box = el('p', 'feed-empty'); + box.textContent = kind === 'news' + ? 'No community news published yet. Posts from the daily feed land here.' + : 'Nothing published in this section yet.'; + node.appendChild(box); + } + + function card(p) { + var a = el('a', 'card'); + a.href = p.link; + if (p.image) { + var img = el('img', 'card-img'); + img.src = p.image; + img.alt = ''; + img.loading = 'lazy'; + img.width = 1000; + img.height = 666; + a.appendChild(img); + } + a.appendChild(el('span', 'card-cat', BADGE[p.category] || p.category)); + a.appendChild(el('h3', null, p.headline)); + a.appendChild(el('p', 'card-desc', p.teaser)); + a.appendChild(el('span', 'card-meta', fmtDate(p.date))); + return a; + } + + function newsRow(p) { + var li = el('li'); + var when = el('span', 'news-date'); + var t = el('time'); + t.dateTime = p.date; + t.textContent = fmtDate(p.date); + when.appendChild(t); + + var body = el('div', 'news-body'); + var h3 = el('h3'); + var a = el('a', null, p.headline); + a.href = p.link; + h3.appendChild(a); + body.appendChild(h3); + body.appendChild(el('p', null, p.teaser)); + + li.appendChild(when); + li.appendChild(body); + li.appendChild(el('span', 'news-source', p.category === 'tips' ? 'Tips & Tricks' : 'Community')); + return li; + } + + function render(view, posts, term) { + var list = posts.filter(function (p) { + return (view.all || p.category === view.category) && matches(p, term); + }); + var node = view.node; + if (view.countBox) { + view.countBox.textContent = list.length + (list.length === 1 ? ' post' : ' posts'); + } + if (!list.length) { + emptyState(node, view.kind); + return; + } + node.textContent = ''; + if (view.kind === 'news') { + list.slice(0, view.limit || 5).forEach(function (p) { node.appendChild(newsRow(p)); }); + } else { + list.slice(0, view.limit || 50).forEach(function (p) { node.appendChild(card(p)); }); + } + if (view.count) view.count.textContent = String(list.length); + } + + function pickCategory(p) { + var c = (p.category || 'blog').toLowerCase(); + return BADGE[c] ? c : 'blog'; + } + + fetch(SOURCE + '?_=' + Date.now()) + .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); }) + .then(function (data) { + var posts = (data && data.posts ? data.posts : []).map(function (p) { + return { + id: p.id, date: p.date, headline: p.headline, teaser: p.teaser || '', + image: p.image || null, link: p.link || '#', + category: pickCategory(p), tags: p.tags || [] + }; + }); + + var views = [ + { node: document.getElementById('tipsGrid'), category: 'tips', kind: 'cards', + search: document.getElementById('tipsSearch'), count: document.getElementById('tipsCount'), limit: 3 }, + { node: document.getElementById('newsList'), category: 'news', kind: 'news', + search: document.getElementById('newsSearch'), count: document.getElementById('newsCount'), limit: 6 }, + { node: document.getElementById('blogGrid'), category: 'blog', kind: 'cards', all: true, + search: document.getElementById('blogSearch'), count: document.getElementById('blogCount'), limit: 60 } + ].filter(function (v) { return v.node; }); + + views.forEach(function (v) { + v.term = ''; + if (v.count) v.countBox = v.count.parentElement; + render(v, posts, ''); + if (v.search) { + v.search.addEventListener('input', function () { + v.term = this.value.trim().toLowerCase(); + render(v, posts, v.term); + }); + } + }); + + /* posts tagged both tips and blog are listed twice on purpose: + the sections are channels, not storage. */ + var total = document.getElementById('feedTotal'); + if (total) total.textContent = String(posts.length); + }) + .catch(function () { + ['tipsGrid', 'newsList', 'blogGrid'].forEach(function (id) { + var n = document.getElementById(id); + if (n) emptyState(n, id === 'newsList' ? 'news' : 'cards'); + }); + }); +})(); diff --git a/scripts/sync_posts.py b/scripts/sync_posts.py new file mode 100644 index 0000000..d44b968 --- /dev/null +++ b/scripts/sync_posts.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Pull the day's posts from the n8n webhook and publish them into the site. + +Runs once a day from cron. Everything it writes is derived from the webhook +payload: post pages under blog/.html, images under assets/blog/, and the +manifest at data/posts.json which the homepage reads. + +Rules (from MANIFESTO.md / STYLEGUIDE.md): + - only real posts are published; attachments that are not images are ignored + - every published item keeps its date and category, no undated entries + - duplicates (same id, same link, same content hash) are never published twice + +Usage: + sync_posts.py fetch, publish, update manifest + sync_posts.py --dry-run fetch and report, write nothing + sync_posts.py --fixture f.json read a saved payload instead of the webhook + sync_posts.py --check report manifest state and exit +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import os +import re +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import date, datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "data" / "posts.json" +POSTS_DIR = ROOT / "blog" +ASSETS_DIR = ROOT / "assets" / "blog" +LOCK = ROOT / "data" / ".sync.lock" +DEFAULT_URL = "https://n8n.odoo4projects.com/webhook/oc-guy/posts" +UA = "oc-guy-sync/1.0 (+https://oc-guy.com)" +MAX_IMAGE_BYTES = 8 * 1024 * 1024 + +IMAGE_KEYS = ("image", "images", "cover", "banner", "picture", "photo", "featured_image", "file") +FILE_KEYS = ("file", "files", "attachment", "attachments", "document", "documents", "file_url") +TITLE_KEYS = ("header", "headline", "title", "name", "subject") +BODY_KEYS = ("text", "body", "content", "html", "markdown", "description", "story") +CATEGORY_KEYS = ("category", "area", "tags", "tag", "section", "type") +DATE_KEYS = ("scheduled", "date", "published", "pubDate", "created", "timestamp") +ID_KEYS = ("id", "slug", "uuid", "_id") + +KNOWN_CATEGORIES = {"tips": "tips", "tip": "tips", "tricks": "tips", + "news": "news", "community": "news", "oca": "news", + "blog": "blog", "article": "blog", "post": "blog"} + + +def log(msg: str) -> None: + print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%SZ')}] {msg}", flush=True) + + +def fetch(url: str, timeout: int = 30) -> object: + """GET the webhook, fall back to POST — n8n registers one method, not both.""" + last = None + for method in ("GET", "POST"): + req = urllib.request.Request(url, method=method, headers={ + "User-Agent": UA, "Accept": "application/json", + "Content-Type": "application/json", + }, data=b"{}" if method == "POST" else None) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + last = f"{method} {url} -> HTTP {e.code} {e.reason}" + except Exception as e: # noqa: BLE001 - network, DNS, JSON + last = f"{method} {url} -> {type(e).__name__}: {e}" + raise RuntimeError(last or "webhook unreachable") + + +def unwrap(payload: object) -> list[dict]: + """n8n hands back a list, or {data:[...]}, or {posts:[...]} — accept all three.""" + if isinstance(payload, list): + return [p for p in payload if isinstance(p, dict)] + if isinstance(payload, dict): + for key in ("posts", "data", "items", "records", "results"): + v = payload.get(key) + if isinstance(v, list): + return [p for p in v if isinstance(p, dict)] + if any(k in payload for k in TITLE_KEYS): + return [payload] + return [] + + +def pick(item: dict, keys: tuple[str, ...]) -> object: + for k in keys: + if k in item and item[k] not in (None, "", []): + return item[k] + return None + + +def slugify(text: str, fallback: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-") + return s[:80] or fallback + + +def normalise_date(value: object) -> str: + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value, timezone.utc).date().isoformat() + if isinstance(value, str): + m = re.match(r"(\d{4}-\d{2}-\d{2})", value) + if m: + return m.group(1) + for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%Y/%m/%d"): + try: + return datetime.strptime(value, fmt).date().isoformat() + except ValueError: + pass + return date.today().isoformat() + + +def normalise_category(value: object) -> str: + """Map whatever tag comes in onto the three site sections.""" + raw: list[str] = [] + if isinstance(value, str): + raw = [v.strip().lower() for v in re.split(r"[,\s]+", value) if v.strip()] + elif isinstance(value, list): + raw = [str(v).strip().lower() for v in value if str(v).strip()] + for tag in raw: + if tag in KNOWN_CATEGORIES: + return KNOWN_CATEGORIES[tag] + return "blog" + + +def image_urls(value: object) -> list[str]: + out: list[str] = [] + if isinstance(value, str): + if re.search(r"\.(jpe?g|png|webp|avif|gif)(\?|$)", value, re.I) or value.startswith("data:image/"): + out.append(value) + elif isinstance(value, list): + for v in value: + out.extend(image_urls(v)) + elif isinstance(value, dict): + for key in ("url", "src", "path", "link"): + if isinstance(value.get(key), str): + out.extend(image_urls(value[key])) + return out + + +def strip_tags(raw: str) -> str: + text = re.sub(r"(?is)<(script|style).*?", " ", raw) + text = re.sub(r"(?s)<[^>]+>", " ", text) + return re.sub(r"\s+", " ", html.unescape(text)).strip() + + +def safe_html(raw: str) -> str: + """Keep the post's formatting, drop anything executable or remote-loading.""" + out = re.sub(r"(?is)<(script|style|iframe|object|embed|form)[^>]*>.*?", "", raw) + out = re.sub(r"(?i)\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", out) + out = re.sub(r"(?i)(href|src)\s*=\s*(\"|')javascript:[^\"']*(\"|')", r'\1="#"', out) + return out.strip() + + +def download_image(src: str, dest_stem: Path) -> str | None: + """Save one image next to the post. Anything that is not an image is dropped.""" + try: + if src.startswith("data:image/"): + header, _, b64 = src.partition(",") + import base64 + blob = base64.b64decode(b64) + ext = (re.match(r"data:image/(\w+)", header) or [None, "png"])[1] + ext = "jpg" if ext == "jpeg" else ext + data, ctype = blob, f"image/{ext}" + else: + req = urllib.request.Request(src, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=45) as r: + ctype = (r.headers.get("Content-Type") or "").split(";")[0].strip().lower() + data = r.read(MAX_IMAGE_BYTES + 1) + except Exception as e: # noqa: BLE001 + log(f" image failed ({type(e).__name__}): {src[:90]}") + return None + + if not ctype.startswith("image/"): + log(f" skipped non-image file: {src[:90]} ({ctype or 'unknown type'})") + return None + if len(data) > MAX_IMAGE_BYTES: + log(f" skipped oversized image: {src[:90]}") + return None + if len(data) < 64: + log(f" skipped empty image: {src[:90]}") + return None + + ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", + "image/avif": "avif", "image/gif": "gif"}.get(ctype, "img") + dest = dest_stem.with_suffix("." + ext) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + return str(dest.relative_to(ROOT)) + + +POST_PAGE = """ + + + + +{title} — The OC Guy + + + + + + + + + +
    + The OC Guy + +
    +
    + +

    {title}

    + {image} +
    {body}
    +

    Published {date_human} · Odoo Community {category} by The OC Guy. + Conditions under which this advice fails are stated in the text; if they are missing, the post is not finished.

    +
    +
    + +
    +

    Odoo Community advocate. Written by Oliver Arnold, 安雷威 — no partner programme, no licences sold.

    + +
    +
    + + +""" + +MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + +def human_date(iso: str) -> str: + try: + d = datetime.strptime(iso, "%Y-%m-%d") + return f"{d.day} {MONTHS[d.month - 1]} {d.year}" + except ValueError: + return iso + + +def build_entry(item: dict, seen_ids: set[str], seen_hashes: set[str], dry: bool) -> dict | None: + title = str(pick(item, TITLE_KEYS) or "").strip() + body_raw = str(pick(item, BODY_KEYS) or "").strip() + if not title and not body_raw: + return None + + iso = normalise_date(pick(item, DATE_KEYS)) + category = normalise_category(pick(item, CATEGORY_KEYS)) + raw_id = str(pick(item, ID_KEYS) or "").strip() + if not title: + title = strip_tags(body_raw)[:80] + # a URL wants the headline, not a database id: "17.html" tells a reader nothing + base = slugify(title, slugify(raw_id, f"post-{iso}")) + sid = raw_id or base + + body_html = safe_html(body_raw) + body_text = strip_tags(body_raw) + digest = hashlib.sha256((title + "\u0000" + body_text).encode("utf-8")).hexdigest()[:16] + + if sid in seen_ids or base in seen_ids: + log(f" duplicate id, skipped: {sid}") + return None + if digest in seen_hashes: + log(f" duplicate content, skipped: {title[:60]}") + return None + + teaser = body_text[:200].rsplit(" ", 1)[0] + "…" if len(body_text) > 200 else body_text + image_rel = None + sources = image_urls(pick(item, IMAGE_KEYS)) + if not sources: + # an item carrying only a non-image attachment (pdf, docx, zip) is not a post + for key in FILE_KEYS: + value = pick(item, (key,)) + for ref in image_urls(value) or ([value] if isinstance(value, str) else []): + if isinstance(ref, str) and re.search(r"\.\w{2,5}(\?|$)", ref) and not re.search( + r"\.(jpe?g|png|webp|avif|gif)(\?|$)", ref, re.I): + log(f" skipped non-post attachment: {base} ({ref[:80]})") + return None + if sources: + if dry: + image_rel = f"assets/blog/{base}." + log(f" [dry] would download {len(sources)} image(s), first: {sources[0][:80]}") + else: + image_rel = download_image(sources[0], ASSETS_DIR / base) + else: + log(f" no image in payload: {base}") + + if not dry: + POSTS_DIR.mkdir(parents=True, exist_ok=True) + page = POST_PAGE.format( + title=html.escape(title), + description=html.escape(teaser)[:180], + category=html.escape(category), + date=iso, + date_human=html.escape(human_date(iso)), + image=f'' if image_rel else "", + body=body_html or f"

    {html.escape(body_text)}

    ", + ) + (POSTS_DIR / f"{base}.html").write_text(page, encoding="utf-8") + + return { + "id": base, + "source_id": sid, + "date": iso, + "category": category, + "headline": title, + "teaser": teaser, + "image": image_rel, + "link": f"blog/{base}.html", + "hash": digest, + "published": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + + +def load_manifest() -> dict: + if MANIFEST.exists(): + try: + return json.loads(MANIFEST.read_text(encoding="utf-8")) + except json.JSONDecodeError: + log("manifest unreadable, starting a new one") + return {"generated": None, "source": DEFAULT_URL, "posts": []} + + +def write_manifest(manifest: dict) -> None: + MANIFEST.parent.mkdir(parents=True, exist_ok=True) + manifest["posts"].sort(key=lambda p: (p.get("date", ""), p.get("published", "")), reverse=True) + fd, tmp = tempfile.mkstemp(dir=str(MANIFEST.parent), suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, ensure_ascii=False, indent=2) + fh.write("\n") + os.replace(tmp, MANIFEST) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--url", default=os.environ.get("OC_GUY_POSTS_WEBHOOK", DEFAULT_URL)) + ap.add_argument("--fixture", help="read a saved JSON payload instead of the webhook") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--check", action="store_true") + args = ap.parse_args() + + manifest = load_manifest() + posts = manifest.get("posts", []) + + if args.check: + log(f"manifest: {len(posts)} post(s)") + for p in posts[:10]: + log(f" {p['date']} {p['category']:<5} {p['id']}") + return 0 + + if LOCK.exists(): + log("another sync is running, exiting") + return 0 + LOCK.parent.mkdir(parents=True, exist_ok=True) + LOCK.write_text(str(os.getpid()), encoding="utf-8") + + try: + if args.fixture: + payload = json.loads(Path(args.fixture).read_text(encoding="utf-8")) + else: + try: + payload = fetch(args.url) + except Exception as e: # noqa: BLE001 + log(f"ERROR webhook unavailable: {e}") + log("nothing published, manifest untouched") + return 2 + + items = unwrap(payload) + log(f"payload: {len(items)} item(s)") + + seen_ids = {p.get("id") for p in posts} | {p.get("source_id") for p in posts} + seen_hashes = {p.get("hash") for p in posts} + new_entries = [] + + for item in items: + entry = build_entry(item, seen_ids, seen_hashes, args.dry_run) + if not entry: + continue + if args.dry_run: + log(f" [dry] would publish: {entry['category']:<5} {entry['date']} {entry['headline'][:70]}") + continue + seen_ids.add(entry["id"]) + seen_ids.add(entry["source_id"]) + seen_hashes.add(entry["hash"]) + new_entries.append(entry) + log(f" published: {entry['category']:<5} {entry['date']} {entry['id']}") + + if args.dry_run: + log("dry run: nothing written") + return 0 + + if new_entries: + manifest["posts"] = new_entries + posts + manifest["generated"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + manifest["source"] = args.url + write_manifest(manifest) + log(f"manifest updated: {len(new_entries)} new, {len(manifest['posts'])} total") + else: + log("no new posts") + return 0 + finally: + LOCK.unlink(missing_ok=True) + + +if __name__ == "__main__": + sys.exit(main())