Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Episode notes and media links live in [logseq-encode-garden](https://github.com/codekiln/logseq-encode-garden). Gitpa mirrors each session under its exact page name, together with its embedded audio asset page. The website and RSS read those checked-in pages.

To refresh an episode, run `mise run episode:sync -- --source /path/to/logseq-encode-garden 'GitP/A/Session/26/09/24-Thu'`. Sync replaces the page body and preserves Gitpa's page properties. A destination page without a matching proxy URL stops the whole batch before writing. Relative assets are copied; missing source assets are reported.
To mirror or refresh an episode, run the garden's sync task, imported into `gitp-garden/mise-tasks/`: `mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination gitp-garden --page 'GitP/A/Session/26/09/24-Thu' --follow-embeds --apply`. `--follow-embeds` brings the recording, artwork, preset and MIDI pages the session embeds in the same batch; leave out `--apply` to preview. The page bodies are replaced with the garden's. Properties follow the garden too, except `tags::`, `public::`, `podcast-guid::` and `podcast-published-at::`, which stay as Gitpa set them; the preview lists each property it adds, updates or removes. A destination page without a matching proxy URL stops the batch before writing. The task also brings each page's entity definitions and records what it imported in `gitp-garden/.logseq-proxy/manifest.json`. Re-running it against unchanged garden pages changes nothing except `logseq-proxy-last-sync-date::`.

To re-sync a page already mirrored here, run the garden's own task, imported into `gitp-garden/mise-tasks/`: `mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination gitp-garden --page 'GitP/A/Session/26/09/24-Thu' --apply`. It also brings the page's entity definitions and records what it imported in `gitp-garden/.logseq-proxy/manifest.json`. Re-running it against unchanged garden pages changes nothing except `logseq-proxy-last-sync-date::`.
To update the imported sync task itself, sync its entity definition: `mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination gitp-garden --page 'Logseq/Entity/Proxy/Page' --apply`.

Set `public:: true` on the episode page to release the episode on the website and RSS. The website build turns embedded recording and artwork pages into direct audio and image links, and labels preset and MIDI downloads. Proxy and storage properties remain in the checked-in garden; the exported listener view hides page properties. A `Notes` or `Production notes` heading publishes its children only when the heading carries `public:: true`. The website build derives asset-page visibility from public episodes; shared assets stay visible while any public episode links to them. The episode's `podcast-guid::` identifies its RSS item permanently; `podcast-published-at::` holds its publication time with a timezone offset. The RSS builder reads the title and description from the session's opening heading and paragraph, follows its embedded MP3 asset page, and gets the enclosure size from the public MP3 server. `public:: false` keeps an episode out of the website and RSS.

Expand Down
4 changes: 2 additions & 2 deletions gitp-garden/.logseq-proxy/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
},
"mise-tasks/logseq/entity/proxy/page/lib/core.py": {
"mode": 420,
"sha256": "3b861f705281fa0cddbd847065d76776354a108aacf7b9f143bf28b3a473267f",
"sha256": "b29946f9062c099cc0c7c3a8077b5300b8582796674b919b4e273323b781b3df",
"source": [
"github.com/codekiln/logseq-encode-garden",
".",
Expand All @@ -101,7 +101,7 @@
},
"mise-tasks/logseq/entity/proxy/page/sync": {
"mode": 493,
"sha256": "9c8a8eb5fd9f10c3972b427210eac1843ad024a547fea8c22453ae9e7b9178ab",
"sha256": "4dc4adbcd690cb3d37fac58a7471454dc019d57160e8afc609d2508b1632a5ff",
"source": [
"github.com/codekiln/logseq-encode-garden",
".",
Expand Down
82 changes: 73 additions & 9 deletions gitp-garden/mise-tasks/logseq/entity/proxy/page/lib/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from zoneinfo import ZoneInfo

PROXY_KEYS = ('logseq-proxy-url', 'logseq-proxy-codeforge-url', 'logseq-proxy-last-sync-date')
PROXY_ENTITY = 'Logseq/Entity/Proxy/Page'
# An entity definition lists the properties a proxy's destination garden owns.
DESTINATION_PROPERTIES = 'entity-proxy-destination-properties'
EMBED = re.compile(r'\{\{embed\s+\[\[([^\]]+)\]\]\s*\}\}')
TRANSACTION = '.logseq-proxy-transaction'
PROPERTY = re.compile(r'^([^\s:]+):: (.*?)(?:\r?\n)?$')

Expand All @@ -39,6 +43,7 @@ class Plan:
expected_modes: dict[str, int | None] = field(default_factory=dict)
source_expected: dict[Path, bytes] = field(default_factory=dict)
source_modes: dict[Path, int] = field(default_factory=dict)
follow_embeds: bool = False


def safe_path(root: Path, relative: str) -> Path:
Expand Down Expand Up @@ -92,6 +97,60 @@ def parse_properties(text: str) -> tuple[dict[str, str], str]:
return props, body


def property_key(line: str) -> str:
return PROPERTY.match(line).group(1)


def with_proxy_membership(lines: list[str]) -> list[str]:
"""Add the Proxy Page entity to logseq-entity, creating the property if needed."""
marker = f'[[{PROXY_ENTITY}]]'
if not any(property_key(line) == 'logseq-entity' for line in lines):
return [f'logseq-entity:: {marker}\n'] + lines
return [line.rstrip('\r\n') + f', {marker}\n'
if property_key(line) == 'logseq-entity' and marker not in line else line
for line in lines]


def destination_owned(plan: Plan, source_props: dict[str, str]) -> set[str]:
"""Properties a re-sync keeps from the destination: tags plus each definition's declared keys."""
keys = {'tags'}
entities = set(re.findall(r'\[\[([^\]]+)\]\]', source_props.get('logseq-entity', ''))) | {PROXY_ENTITY}
for entity in sorted(entities):
definition = resolve_page(plan.source_root, entity)
if not definition.is_file():
continue
data = definition.read_bytes()
plan.source_expected[definition] = data
props, _ = parse_properties(data.decode('utf-8'))
keys.update(key.strip() for key in props.get(DESTINATION_PROPERTIES, '').split(',') if key.strip())
return keys


def merge_properties(destination: list[str], source: list[str], owned: set[str]) -> tuple[list[str], list[str]]:
"""Take source properties on re-sync, keeping destination-owned lines and the destination's order."""
incoming = {property_key(line): line for line in source if property_key(line) not in PROXY_KEYS}
merged, changes, seen = [], [], set()
for line in destination:
key = property_key(line)
if key in PROXY_KEYS:
continue
seen.add(key)
if key in owned:
merged.append(line)
elif key not in incoming:
changes.append(f'removed {key}')
elif incoming[key].rstrip('\r\n') == line.rstrip('\r\n'):
merged.append(line)
else:
merged.append(incoming[key])
changes.append(f'updated {key}')
for key, line in incoming.items():
if key not in seen and key not in owned:
merged.append(line)
changes.append(f'added {key}')
return merged, changes


def page_filename(page: str) -> str:
if not page or page.startswith('/') or any(x in ('', '.', '..') for x in page.split('/')) or '\\' in page:
raise SyncError(f'Unsafe page name: {page}')
Expand Down Expand Up @@ -268,14 +327,11 @@ def add_page(plan: Plan, page: str) -> None:
raise SyncError(f'Proxy source conflict: {target}')
if props.get('logseq-proxy-codeforge-url'):
validate_codeforge(props['logseq-proxy-codeforge-url'], plan.source_root, source)
lines, _ = property_lines(old_text)
old_lines, _ = property_lines(old_text)
lines, changes = merge_properties(old_lines, with_proxy_membership(source_lines), destination_owned(plan, source_props))
else:
lines = source_lines
if 'logseq-entity' in source_props:
lines = [line.rstrip('\r\n') + ', [[Logseq/Entity/Proxy/Page]]\n' if PROPERTY.match(line).group(1) == 'logseq-entity' and '[[Logseq/Entity/Proxy/Page]]' not in source_props['logseq-entity'] else line for line in lines]
else:
lines = ['logseq-entity:: [[Logseq/Entity/Proxy/Page]]\n'] + lines
retained = [line for line in lines if PROPERTY.match(line).group(1) not in PROXY_KEYS]
lines, changes = with_proxy_membership(source_lines), []
retained = [line for line in lines if property_key(line) not in PROXY_KEYS]
# A final property without a newline still needs to remain a separate property.
retained = [line if line.endswith('\n') else line + '\n' for line in retained]
metadata = {'logseq-proxy-url': graph_url(plan.source_root, page)}
Expand All @@ -289,6 +345,7 @@ def add_page(plan: Plan, page: str) -> None:
plan.pages.add(page)
plan.details.append(f'{"Create" if first else "Re-sync"} {source} -> {target}')
plan.details.extend(f' {key}:: {value}' for key, value in metadata.items())
plan.details.extend(f' property {change}' for change in changes)
# Capture Markdown link targets, allowing <...> around paths with literal spaces.
assets = re.findall(r'\]\(\s*(?:<([^>]+)>|([^\s)]+))', body)
for a, b in assets:
Expand All @@ -305,14 +362,21 @@ def add_page(plan: Plan, page: str) -> None:
plan.source_expected[asset] = asset_data
add_write(plan, relative, asset_data)
plan.details.append(f'Asset {asset} -> {plan.destination_root / relative}')
if plan.follow_embeds:
for embedded in EMBED.findall(body):
if resolve_page(plan.source_root, embedded).exists():
add_page(plan, embedded)
else:
plan.warnings.append(f'Missing embedded page: {embedded}')


def build_page_plan(source: Path | None, destination: Path, page: str, codeforge_url: str | None = None) -> Plan:
def build_page_plan(source: Path | None, destination: Path, page: str, codeforge_url: str | None = None,
follow_embeds: bool = False) -> Plan:
destination = validate_graph(destination)
source = validate_graph(source if source is not None else resolve_source(destination, page))
if source == destination:
raise SyncError('Source and destination must be different graphs')
plan = Plan(source, destination, page, codeforge_url=codeforge_url)
plan = Plan(source, destination, page, codeforge_url=codeforge_url, follow_embeds=follow_embeds)
add_page(plan, page)
return plan

Expand Down
9 changes: 6 additions & 3 deletions gitp-garden/mise-tasks/logseq/entity/proxy/page/sync
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#USAGE flag "--destination <graph>" required=#true help="Destination graph root"
#USAGE flag "--page <name>" help="Exact logical page name"
#USAGE flag "--codeforge-url <url>" help="Source page URL on a code forge"
#USAGE flag "--follow-embeds" help="Also sync pages the page embeds, recursively"
#USAGE flag "--apply" help="Apply the previewed import"
#USAGE flag "--recover" help="Roll back an interrupted import, then exit"
"""Task reference: [[Logseq/Entity/Proxy/Page/mise/Task/sync]]."""
Expand All @@ -23,6 +24,7 @@ def main():
parser.add_argument('--destination', type=Path, required=True)
parser.add_argument('--page')
parser.add_argument('--codeforge-url')
parser.add_argument('--follow-embeds', action='store_true')
parser.add_argument('--apply', action='store_true')
parser.add_argument('--recover', action='store_true')
args = list(sys.argv[1:])
Expand All @@ -31,9 +33,9 @@ def main():
for name in ('source', 'destination', 'page', 'codeforge_url'):
if os.environ.get('usage_' + name):
args.extend(['--' + name.replace('_', '-'), os.environ['usage_' + name]])
for name in ('apply', 'recover'):
for name in ('follow_embeds', 'apply', 'recover'):
if os.environ.get('usage_' + name) == 'true':
args.append('--' + name)
args.append('--' + name.replace('_', '-'))
options = parser.parse_args(args)
try:
if options.recover:
Expand All @@ -42,7 +44,8 @@ def main():
return 0
if not options.page:
parser.error('--page is required unless --recover is used')
plan = build_page_plan(options.source, options.destination, options.page, options.codeforge_url)
plan = build_page_plan(options.source, options.destination, options.page, options.codeforge_url,
options.follow_embeds)
try:
import companions
except ModuleNotFoundError as error:
Expand Down
3 changes: 3 additions & 0 deletions gitp-garden/pages/Logseq___Entity___Definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ logseq-proxy-last-sync-date:: [[2026-10-06]]
- [[Logseq/Entity/Proxy/Page/mise/Task/sync]] follows an instance's `logseq-entity::` links, imports its entity definitions, and follows each definition's `entity-tasks::` links. Task references declare their executable files and required task dependencies through the companion-file contract on [[Logseq/Entity/Mise/Task]].
- Shared entity definitions and task dependencies are visited once per sync. Cyclic relationships are allowed and terminate after their pages have been visited.
- A referenced entity definition with no source file is reported as a logical page. A declared companion task or task dependency requires a source reference page and its mapped implementation files; missing task content stops application.
- ## Properties a proxy's destination owns
- `entity-proxy-destination-properties::` names, as a comma-separated list of property keys, the properties on an instance that belong to the garden holding a [[Logseq/Entity/Proxy/Page]] of it rather than to the source page. Re-syncing that proxy keeps the destination's values for them. An absent property names none.
- For example, [[Logseq/Entity/Podcast/Episode]] names `podcast-guid` and `podcast-published-at`, which the publishing garden records.
- ## Relationship to [[Logseq/Entity]]
- [[Logseq/Entity]] is the general conceptual model — what an entity is and how any page is marked as one. This page is the entity type whose instances are the definition pages themselves, so the garden's set of entity types is queryable through its backlinks rather than kept as a hand-maintained list.
2 changes: 1 addition & 1 deletion gitp-garden/pages/Logseq___Entity___Mise___Task.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ logseq-proxy-last-sync-date:: [[2026-10-06]]
- Built-in mise subcommands such as `mise run` are [[Logseq/Entity/CLI/Command]] instances. Jobs invoked through them are Mise Task instances.
- ## Naming and links
- Task reference pages belong to the topic served by the job: `Topic/mise/Task/<task-name-with-colons-as-slashes>`. A namespace prefix already expressed by the topic can be omitted from the page name while remaining in `task-name::`.
- For example, `episode:sync` lives at [[GitP/mise/Task/episode/sync]]. Repository ownership remains explicit in frontmatter.
- For example, `logseq:entity:proxy:page:sync` lives at [[Logseq/Entity/Proxy/Page/mise/Task/sync]]: the topic already expresses `logseq:entity:proxy:page`. Repository ownership remains explicit in frontmatter.
- If the topic contains distinct implementations of the same task name, add a repository or config-scope segment before the task path. Displayed invocation text retains the exact runnable name.
- ## Frontmatter
- `logseq-entity::` marks the page as a Mise Task instance.
Expand Down
3 changes: 2 additions & 1 deletion gitp-garden/pages/Logseq___Entity___Podcast___Episode.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
logseq-entity:: [[Logseq/Entity/Definition]], [[Logseq/Entity/Proxy/Page]]
entity-proxy-destination-properties:: podcast-guid, podcast-published-at
logseq-proxy-url:: logseq://graph/logseq-encode-garden?page=Logseq%2FEntity%2FPodcast%2FEpisode
logseq-proxy-codeforge-url:: https://github.com/codekiln/logseq-encode-garden/blob/main/pages/Logseq___Entity___Podcast___Episode.md
logseq-proxy-last-sync-date:: [[2026-10-06]]
Expand Down Expand Up @@ -28,7 +29,7 @@ logseq-proxy-last-sync-date:: [[2026-10-06]]
- Mark instances with **`logseq-entity:: [[Logseq/Entity/Podcast/Episode]]`** so this definition page collects backlinks to every episode.
- Set **`created-by::`** to the host.
- Set **`date-created::`** to the air date for listening notes, or the recording date for a producer-owned session.
- The publishing garden records feed identity with [[Logseq/Entity/Podcast/Episode/Frontmatter/podcast-guid]] and publication time with [[Logseq/Entity/Podcast/Episode/Frontmatter/podcast-published-at]].
- The publishing garden records feed identity with [[Logseq/Entity/Podcast/Episode/Frontmatter/podcast-guid]] and publication time with [[Logseq/Entity/Podcast/Episode/Frontmatter/podcast-published-at]]. When the episode page there is a [[Logseq/Entity/Proxy/Page]], re-syncing it keeps both.
- Optional: **`logseq-created-time-year::`** linking to the matching [[Logseq/Entity/Time/Year]] instance; see that page for how it differs from `date-created::`.
- **Never add, remove, or edit a `tags::` line on an existing page.** Older episode pages carry assorted `tags::` values; those are the author's and stay as they are.
- ## Page shape
Expand Down
Loading
Loading