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
8 changes: 7 additions & 1 deletion journals/2026_10_06.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,11 @@
- [[TWG]]
- entity work
- [[Logseq/Entity/Log/26/10/05 Mon - Mise Tasks Accompany Logseq Proxies/Plan]]
- [[Logseq/Entity/Definition]]
- [[Logseq/Entity/Mise/Task]]
- [[Logseq/Entity/Podcast/Episode]]
- [[Logseq/Entity/Proxy/Page]]
- [[Logseq/Entity/Proxy/Page/mise/Task/sync]]
- gitpa
- [[GitP/A/Log/26/10/05 Mon - simplify gitpa garden proxies]]
- [[GitP/A/Log/26/10/05 Mon - simplify gitpa garden proxies]]
- [[GitP/How To/Prepare Podcast Metadata]]
82 changes: 73 additions & 9 deletions 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 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
53 changes: 50 additions & 3 deletions mise-tasks/logseq/entity/proxy/page/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def graph(self, name):
def plan(self):
return build_page_plan(self.source, self.destination, self.page)

def test_preview_first_copy_and_resync_preserve_exact_properties(self):
def test_preview_first_copy_and_resync_take_source_properties(self):
plan = self.plan()
target = resolve_page(self.destination, self.page)
self.assertFalse(target.exists())
Expand All @@ -51,14 +51,61 @@ def test_preview_first_copy_and_resync_preserve_exact_properties(self):
self.source_page.write_text('tags:: [[Changed]]\nnew-key:: source only\n- fresh\n')
apply_plan(self.plan())
text = target.read_bytes().decode()
self.assertTrue(text.startswith(original.split('logseq-proxy-url')[0]))
self.assertNotIn('new-key::', text)
self.assertTrue(text.startswith('tags:: [[Local]]\r\nlogseq-entity:: [[Logseq/Entity/Proxy/Page]]\nnew-key:: source only\n'))
self.assertNotIn('owner::', text)
self.assertIn('- fresh\n', text)
second = self.plan()
before = target.stat().st_mtime_ns
apply_plan(second)
self.assertEqual(target.stat().st_mtime_ns, before)

def test_resync_keeps_properties_definitions_give_the_destination(self):
(self.source / 'pages/Logseq___Entity___Book.md').write_text(
'entity-proxy-destination-properties:: podcast-guid, podcast-published-at\n- # Book\n')
(self.source / 'pages/Logseq___Entity___Proxy___Page.md').write_text(
'entity-proxy-destination-properties:: public\n- # Proxy Page\n')
self.source_page.write_text('tags:: [[Original]]\nlogseq-entity:: [[Logseq/Entity/Book]]\n'
'kept:: same\nchanged:: old\ndropped:: soon\n- body\n')
apply_plan(self.plan())
target = resolve_page(self.destination, self.page)
target.write_text('public:: true\npodcast-guid:: stable\nlocal:: note\n' + target.read_text())
self.source_page.write_text('tags:: [[Changed]]\nlogseq-entity:: [[Logseq/Entity/Book]]\n'
'kept:: same\nchanged:: new\nadded:: later\npodcast-guid:: source\n'
'podcast-published-at:: source\n- body\n')
plan = self.plan()
props, body = parse_properties(plan.writes['pages/Book___A%3F B.md'].decode())
self.assertEqual(props['public'], 'true')
self.assertEqual(props['podcast-guid'], 'stable')
self.assertNotIn('podcast-published-at', props)
self.assertEqual(props['tags'], '[[Original]]')
self.assertEqual(props['logseq-entity'], '[[Logseq/Entity/Book]], [[Logseq/Entity/Proxy/Page]]')
self.assertEqual((props['kept'], props['changed'], props['added']), ('same', 'new', 'later'))
self.assertNotIn('dropped', props)
self.assertNotIn('local', props)
self.assertEqual(body, '- body\n')
for change in ('updated changed', 'added added', 'removed dropped', 'removed local'):
self.assertIn(f' property {change}', plan.details)
self.assertNotIn(' property updated kept', plan.details)
apply_plan(plan)
before = target.read_bytes()
apply_plan(self.plan())
self.assertEqual(target.read_bytes(), before)

def test_follow_embeds_syncs_embedded_pages_recursively(self):
(self.source / 'assets').mkdir()
(self.source / 'assets/take.mp3').write_bytes(b'audio')
self.source_page.write_text('- # Session\n\t- {{embed [[Session/Asset]]}}\n\t- {{embed [[Absent]]}}\n')
(self.source / 'pages/Session___Asset.md').write_text(
'- [Take](../assets/take.mp3)\n- {{embed [[Book/A? B]]}}\n')
self.assertNotIn('pages/Session___Asset.md', self.plan().writes)
plan = build_page_plan(self.source, self.destination, self.page, follow_embeds=True)
self.assertIn('pages/Session___Asset.md', plan.writes)
self.assertEqual(plan.writes['assets/take.mp3'], b'audio')
self.assertIn('Missing embedded page: Absent', plan.warnings)
apply_plan(plan)
props, _ = parse_properties((self.destination / 'pages/Session___Asset.md').read_text())
self.assertEqual(props['logseq-proxy-url'], graph_url(self.source, 'Session/Asset'))

def test_page_collision_and_wrong_proxy_source_have_no_writes(self):
target = resolve_page(self.destination, self.page)
target.write_text('tags:: [[Local]]\n- original\n')
Expand Down
6 changes: 3 additions & 3 deletions pages/GitP___How To___Prepare Podcast Metadata.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
logseq-entity:: [[Logseq/Entity/Diataxis/How To]]
see-also:: [[GitP/mise/Task/episode/sync]], [[GitP/How To/Draft an Episode from Session Assets]]
see-also:: [[Logseq/Entity/Proxy/Page/mise/Task/sync]], [[GitP/How To/Draft an Episode from Session Assets]]
- # Prepare Podcast Metadata
- Prepare the episode in this garden, then mirror it into [[Person/codekiln/GitHub/gitpa]] for publication. The session page supplies the episode text; its linked asset page supplies the recording.
- ## Prepare the session
- Create or update the recording's [[Logseq/Entity/Podcast/Episode]] page under `GitP/A/Session/YY/MM/DD-Day`. Link the music-making log and add the supported session details, preset downloads, and recording asset.
- Give the page one H1 episode title and a short description as its first child. Embed the MP3 asset page beneath it. [[GitP/A/Session/26/09/24-Thu]] is an example.
- Follow [[Logseq/Entity/Asset]] for the recording's asset page. The audio link belongs there; episode pages embed that page.
- ## Mirror into Gitpa
- Run [[GitP/mise/Task/episode/sync]] from the Gitpa checkout with this garden's checkout path and the session page name. It copies the session body and embedded asset pages as [[Logseq/Entity/Proxy/Page]] instances.
- Edit episode text in this garden and sync again. Gitpa keeps the publication properties on its proxy; source text replaces the proxy body.
- Run [[Logseq/Entity/Proxy/Page/mise/Task/sync]] from the Gitpa checkout with this garden's checkout path, `--destination gitp-garden`, the session page name, and `--follow-embeds`. It copies the session and its embedded asset pages as [[Logseq/Entity/Proxy/Page]] instances.
- Edit the episode in this garden and sync again. The proxy's body and properties follow the source; Gitpa keeps `tags::`, `public::`, `podcast-guid::` and `podcast-published-at::`.
- ## Prepare the publication PR
- On the Gitpa proxy, set `public:: true`, `podcast-guid::`, and `podcast-published-at::`. The latter two identify the RSS release and retain their values after publication. `public::` is the publication switch.
- Run `mise run rss:build`, `mise run rss:check`, and `mise run rss:test` in Gitpa. RSS reads the proxy title, description, and recording link directly; enclosure size comes from the public media response.
Expand Down
27 changes: 0 additions & 27 deletions pages/GitP___mise___Task___episode___sync.md

This file was deleted.

3 changes: 3 additions & 0 deletions pages/Logseq___Entity___Definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ see-also:: [[Logseq/Entity/Definition/Discussion]]
- [[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.
Loading