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 0000000..f2d9880 Binary files /dev/null and b/assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png differ 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 + +
+
+

news

+

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.

+
+ + + 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())