authoring server 2
All checks were successful
Build Org Website / build (push) Successful in 45s

This commit is contained in:
2026-05-07 12:30:48 +01:00
parent 72cd3e89ad
commit d3a668f353
4 changed files with 191 additions and 75 deletions

View File

@@ -6,12 +6,13 @@ The site is written using Emacs and Org-mode, and exported using the `org-publis
## Local authoring UI ## Local authoring UI
Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. When deployed behind the reverse proxy, use `https://author.zainezq.com`.
The editor writes normal `.org` pages with the metadata used by the publishing pipeline: The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
- blogs are created under `blogs/YYYY/MM-month/slug.org` - blogs default to `blogs/YYYY/MM-month/slug.org`
- posts are created under `posts/slug.org`, or `posts/section/slug.org` when a section is provided - posts default to `posts/slug.org`, or `posts/section/slug.org` when a section is provided
- new pages can use an explicit path under `blogs/` or `posts/`
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index - saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
While that build is running, the UI disables editing and the API rejects further saves. The status panel unlocks the editor when publishing finishes. Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.

233
authoring_server.py Normal file → Executable file
View File

@@ -10,7 +10,7 @@ import subprocess
import sys import sys
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from email.utils import formatdate from email.utils import formatdate
from http import HTTPStatus from http import HTTPStatus
@@ -112,49 +112,101 @@ class OrgPage:
wip: str | None = None wip: str | None = None
class BuildState: @dataclass
class BuildJob:
id: int
path: str
title: str
queued_at: float = field(default_factory=time.time)
started_at: float | None = None
finished_at: float | None = None
ok: bool | None = None
message: str = "Queued"
log: str = ""
@property
def status(self) -> str:
if self.finished_at is not None:
return "done" if self.ok else "failed"
if self.started_at is not None:
return "running"
return "queued"
def to_dict(self, include_log: bool = False) -> dict[str, Any]:
data = {
"id": self.id,
"path": self.path,
"title": self.title,
"queuedAt": self.queued_at,
"startedAt": self.started_at,
"finishedAt": self.finished_at,
"ok": self.ok,
"status": self.status,
"message": self.message,
}
if include_log:
data["log"] = self.log[-12000:]
return data
class BuildQueue:
def __init__(self) -> None: def __init__(self) -> None:
self._lock = threading.Lock() self._lock = threading.Lock()
self.running = False self._next_id = 1
self.started_at: float | None = None self._pending: list[BuildJob] = []
self.finished_at: float | None = None self._current: BuildJob | None = None
self.ok: bool | None = None self._recent: list[BuildJob] = []
self.message = "No build has run yet." self._worker: threading.Thread | None = None
self.log = ""
def snapshot(self) -> dict[str, Any]: def snapshot(self) -> dict[str, Any]:
with self._lock: with self._lock:
current = self._current.to_dict(include_log=True) if self._current else None
recent = [job.to_dict(include_log=True) for job in self._recent[-10:]]
pending = [job.to_dict() for job in self._pending]
latest = current or (recent[-1] if recent else None)
message = latest["message"] if latest else "No builds have run yet."
return { return {
"running": self.running, "running": current is not None,
"startedAt": self.started_at, "queued": len(pending),
"finishedAt": self.finished_at, "message": message,
"ok": self.ok, "current": current,
"message": self.message, "pending": pending,
"log": self.log[-12000:], "recent": recent,
"log": latest.get("log", "") if latest else "",
} }
def start(self) -> bool: def enqueue(self, path: str, title: str) -> BuildJob:
with self._lock: with self._lock:
if self.running: job = BuildJob(self._next_id, path, title)
return False self._next_id += 1
self.running = True self._pending.append(job)
self.started_at = time.time() if self._worker is None or not self._worker.is_alive():
self.finished_at = None self._worker = threading.Thread(target=self._run_worker, daemon=True)
self.ok = None self._worker.start()
self.message = "Build started. The editor is locked until publishing finishes." return job
self.log = ""
return True
def finish(self, ok: bool, message: str, log: str) -> None: def _run_worker(self) -> None:
with self._lock: while True:
self.running = False with self._lock:
self.finished_at = time.time() if not self._pending:
self.ok = ok self._current = None
self.message = message return
self.log = log job = self._pending.pop(0)
job.started_at = time.time()
job.message = "Publishing site and search index."
self._current = job
ok, message, log = run_build_commands()
with self._lock:
job.finished_at = time.time()
job.ok = ok
job.message = message
job.log = log
self._recent.append(job)
self._recent = self._recent[-20:]
self._current = None
BUILD_STATE = BuildState() BUILD_QUEUE = BuildQueue()
def safe_relative_path(path: str) -> Path: def safe_relative_path(path: str) -> Path:
@@ -171,6 +223,17 @@ def safe_relative_path(path: str) -> Path:
return full return full
def safe_target_path(path: str, slug: str) -> Path:
candidate = path.strip()
if not candidate:
raise ValueError("Path is required.")
if candidate.endswith("/"):
candidate = f"{candidate}{slug}.org"
elif not Path(candidate).suffix:
candidate = f"{candidate}.org"
return safe_relative_path(candidate)
def read_page(path: Path) -> OrgPage: def read_page(path: Path) -> OrgPage:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
meta: dict[str, str] = {} meta: dict[str, str] = {}
@@ -248,6 +311,9 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
return safe_relative_path(existing_path) return safe_relative_path(existing_path)
title = str(data.get("title") or "").strip() title = str(data.get("title") or "").strip()
slug = slugify(str(data.get("slug") or title)) slug = slugify(str(data.get("slug") or title))
explicit_path = str(data.get("targetPath") or "").strip()
if explicit_path:
return safe_target_path(explicit_path, slug)
page_type = str(data.get("pageType") or "blog") page_type = str(data.get("pageType") or "blog")
if page_type == "blog": if page_type == "blog":
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now() dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
@@ -306,7 +372,7 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]:
return page_to_dict(read_page(target)) return page_to_dict(read_page(target))
def run_build() -> None: def run_build_commands() -> tuple[bool, str, str]:
venv_python = ROOT / ".venv" / "bin" / "python" venv_python = ROOT / ".venv" / "bin" / "python"
venv_pip = ROOT / ".venv" / "bin" / "pip" venv_pip = ROOT / ".venv" / "bin" / "pip"
commands = [["emacs", "-Q", "--script", "build-site.el"]] commands = [["emacs", "-Q", "--script", "build-site.el"]]
@@ -335,14 +401,11 @@ def run_build() -> None:
combined.append(f"\nCommand exited with {proc.returncode}.\n") combined.append(f"\nCommand exited with {proc.returncode}.\n")
break break
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below." message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
BUILD_STATE.finish(ok, message, "".join(combined)) return ok, message, "".join(combined)
def trigger_build() -> None: def queue_build(page: dict[str, Any]) -> dict[str, Any]:
if not BUILD_STATE.start(): return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict()
return
thread = threading.Thread(target=run_build, daemon=True)
thread.start()
APP_HTML = r"""<!doctype html> APP_HTML = r"""<!doctype html>
@@ -352,13 +415,12 @@ APP_HTML = r"""<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Org Site Authoring</title> <title>Org Site Authoring</title>
<style> <style>
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; --disabled: #ece7dc; } :root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); } body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
button, input, textarea, select { font: inherit; } button, input, textarea, select { font: inherit; }
button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; } button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: white; border-color: var(--accent); } button.primary { background: var(--accent); color: white; border-color: var(--accent); }
button:disabled, input:disabled, textarea:disabled, select:disabled { cursor: not-allowed; background: var(--disabled); color: var(--muted); }
.app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; } .app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; } aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; } main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
@@ -369,6 +431,7 @@ APP_HTML = r"""<!doctype html>
.status.running { border-color: var(--accent-2); } .status.running { border-color: var(--accent-2); }
.status.ok { border-color: var(--accent); } .status.ok { border-color: var(--accent); }
.status.fail { border-color: #a12a2a; } .status.fail { border-color: #a12a2a; }
.quick-link { display: inline-block; color: var(--accent); font-size: 13px; margin: -6px 0 14px; }
.filters { display: grid; gap: 8px; margin-bottom: 14px; } .filters { display: grid; gap: 8px; margin-bottom: 14px; }
.page-list { display: grid; gap: 7px; } .page-list { display: grid; gap: 7px; }
.page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); } .page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
@@ -381,8 +444,13 @@ APP_HTML = r"""<!doctype html>
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; } textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: var(--muted); font-size: 13px; } .hint { color: var(--muted); font-size: 13px; }
.full { grid-column: 1 / -1; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; } .tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; } .tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
.queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
.queue-item { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 9px 10px; font-size: 13px; }
.queue-item strong { display: block; overflow-wrap: anywhere; }
.queue-item span { color: var(--muted); overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; } pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } } @media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } }
</style> </style>
@@ -394,6 +462,7 @@ APP_HTML = r"""<!doctype html>
<h1>Authoring</h1> <h1>Authoring</h1>
<button id="newBtn" type="button">New</button> <button id="newBtn" type="button">New</button>
</div> </div>
<a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
<div id="status" class="status"></div> <div id="status" class="status"></div>
<div class="filters"> <div class="filters">
<input id="search" type="search" placeholder="Filter pages" /> <input id="search" type="search" placeholder="Filter pages" />
@@ -433,6 +502,9 @@ APP_HTML = r"""<!doctype html>
<label>Date <label>Date
<input name="date" placeholder="<2026-05-07 Thu 12:00>" /> <input name="date" placeholder="<2026-05-07 Thu 12:00>" />
</label> </label>
<label class="full">Org file path
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div> </div>
<label>Content <label>Content
<textarea name="content" spellcheck="true"></textarea> <textarea name="content" spellcheck="true"></textarea>
@@ -444,12 +516,14 @@ APP_HTML = r"""<!doctype html>
<span id="saveMessage" class="hint"></span> <span id="saveMessage" class="hint"></span>
</div> </div>
</form> </form>
<h2>Build log</h2> <h2>Build queue</h2>
<div id="queue" class="queue-list"></div>
<h2>Latest build log</h2>
<pre id="buildLog"></pre> <pre id="buildLog"></pre>
</main> </main>
</div> </div>
<script> <script>
const state = { pages: [], currentPath: "", build: null }; const state = { pages: [], currentPath: "", build: null, pathManual: false };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const editor = $("#editor"); const editor = $("#editor");
const statusBox = $("#status"); const statusBox = $("#status");
@@ -475,18 +549,33 @@ APP_HTML = r"""<!doctype html>
return data; return data;
} }
function setLocked(locked) {
editor.querySelectorAll("input, textarea, select, button").forEach((el) => el.disabled = locked);
$("#newBtn").disabled = locked;
pagesBox.querySelectorAll("button").forEach((el) => el.disabled = locked);
}
function renderStatus() { function renderStatus() {
const build = state.build || {}; const build = state.build || {};
statusBox.className = "status" + (build.running ? " running" : build.ok === true ? " ok" : build.ok === false ? " fail" : ""); const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
statusBox.className = "status" + (build.running ? " running" : latest && latest.ok === true ? " ok" : latest && latest.ok === false ? " fail" : "");
statusBox.textContent = build.message || "Checking build status."; statusBox.textContent = build.message || "Checking build status.";
buildLog.textContent = build.log || ""; buildLog.textContent = build.log || "";
setLocked(Boolean(build.running)); renderQueue();
}
function renderQueue() {
const box = $("#queue");
const build = state.build || {};
const items = [];
if (build.current) items.push(build.current);
(build.pending || []).forEach((job) => items.push(job));
(build.recent || []).slice().reverse().slice(0, 5).forEach((job) => items.push(job));
box.innerHTML = "";
if (!items.length) {
box.innerHTML = `<div class="queue-item"><span>No queued builds.</span></div>`;
return;
}
items.forEach((job) => {
const row = document.createElement("div");
row.className = "queue-item";
row.innerHTML = `<strong>#${job.id} ${html(job.status)} · ${html(job.title)}</strong><span>${html(job.path)} · ${html(job.message)}</span>`;
box.appendChild(row);
});
} }
function renderPages() { function renderPages() {
@@ -514,8 +603,10 @@ APP_HTML = r"""<!doctype html>
function setForm(page) { function setForm(page) {
state.currentPath = page.path || ""; state.currentPath = page.path || "";
state.pathManual = Boolean(page.path);
editor.pageType.value = page.pageType || "blog"; editor.pageType.value = page.pageType || "blog";
editor.section.value = ""; editor.section.value = "";
editor.targetPath.value = page.path || "";
editor.title.value = page.title || ""; editor.title.value = page.title || "";
editor.slug.value = page.slug || slugify(page.title || ""); editor.slug.value = page.slug || slugify(page.title || "");
editor.tags.value = (page.tags || []).join(", "); editor.tags.value = (page.tags || []).join(", ");
@@ -524,6 +615,8 @@ APP_HTML = r"""<!doctype html>
editor.comments.checked = page.comments !== false; editor.comments.checked = page.comments !== false;
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page"; $("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
$("#pathLabel").textContent = state.currentPath; $("#pathLabel").textContent = state.currentPath;
editor.targetPath.disabled = Boolean(state.currentPath);
updateSuggestedPath();
saveMessage.textContent = ""; saveMessage.textContent = "";
} }
@@ -540,32 +633,55 @@ APP_HTML = r"""<!doctype html>
async function refreshBuild() { async function refreshBuild() {
state.build = await api("/api/build"); state.build = await api("/api/build");
renderStatus(); renderStatus();
if (!state.build.running) await refreshPages(); await refreshPages();
} }
editor.title.addEventListener("input", () => { editor.title.addEventListener("input", () => {
if (!state.currentPath) editor.slug.value = slugify(editor.title.value); if (!state.currentPath) {
editor.slug.value = slugify(editor.title.value);
updateSuggestedPath();
}
}); });
editor.slug.addEventListener("input", updateSuggestedPath);
editor.pageType.addEventListener("change", updateSuggestedPath);
editor.section.addEventListener("input", updateSuggestedPath);
editor.date.addEventListener("input", updateSuggestedPath);
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
$("#search").addEventListener("input", renderPages); $("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages); $("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true })); $("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
$("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true })); $("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
editor.addEventListener("submit", async (event) => { editor.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
saveMessage.textContent = "Saving and starting build."; saveMessage.textContent = "Saving and queueing build.";
const payload = Object.fromEntries(new FormData(editor).entries()); const payload = Object.fromEntries(new FormData(editor).entries());
payload.path = state.currentPath; payload.path = state.currentPath;
payload.comments = editor.comments.checked; payload.comments = editor.comments.checked;
try { try {
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) }); const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
setForm(saved); setForm(saved);
saveMessage.textContent = "Build started. Editing is locked until it finishes."; saveMessage.textContent = "Saved. Build queued.";
await refreshBuild(); await refreshBuild();
} catch (err) { } catch (err) {
saveMessage.textContent = err.message; saveMessage.textContent = err.message;
} }
}); });
function updateSuggestedPath() {
if (state.currentPath || state.pathManual) return;
const slug = slugify(editor.slug.value || editor.title.value);
if (editor.pageType.value === "post") {
const section = slugify(editor.section.value || "");
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
return;
}
const match = editor.date.value.match(/<(\d{4})-(\d{2})-/);
const year = match ? match[1] : String(new Date().getFullYear());
const month = match ? Number(match[2]) : new Date().getMonth() + 1;
const months = ["january","february","march","april","may","june","july","august","september","october","november","december"];
editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
}
setForm({ pageType: "blog", date: currentOrgDate(), comments: true }); setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
refreshPages(); refreshPages();
refreshBuild(); refreshBuild();
@@ -613,7 +729,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return return
if parsed.path == "/api/build": if parsed.path == "/api/build":
self.send_json(BUILD_STATE.snapshot()) self.send_json(BUILD_QUEUE.snapshot())
return return
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
@@ -621,14 +737,11 @@ class Handler(BaseHTTPRequestHandler):
if self.path != "/api/page": if self.path != "/api/page":
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
return return
if BUILD_STATE.snapshot()["running"]:
self.send_json({"error": "A build is already running. Wait for it to finish before editing."}, HTTPStatus.CONFLICT)
return
try: try:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length).decode("utf-8")) data = json.loads(self.rfile.read(length).decode("utf-8"))
saved = save_page(data) saved = save_page(data)
trigger_build() saved["queuedBuild"] = queue_build(saved)
self.send_json(saved) self.send_json(saved)
except Exception as exc: except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

View File

@@ -1,10 +1,12 @@
#+TITLE: [03-05-2026] - Weekly Review #+TITLE: [03-05-2026] - Weekly Review
#+OPTIONS: num:nil #+OPTIONS: num:nil
#+DATE: <2026-05-03 Sun 12:00> #+DATE: <2026-05-03 Sun 12:00>
#+filetags: :review: #+filetags: :review:
#+COMMENTS: t #+COMMENTS: t
#+SLUG: 03-05-26-week-review #+SLUG: 03-05-26-week-review
This week was spent finalising the removals of the deployment flags, as well as starting the ESS work. I picked up a task to create a generic import module script. This week was spent finalising the removals of the deployment flags, as well as starting the ESS work. I picked up a task to create a generic import module script.
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]] This is a test
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]]

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts: * Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:18</span>@@ - [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:30</span>@@
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@ - [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@