diff --git a/journals/2026_10_06.md b/journals/2026_10_06.md index 364175bd4..c8186dffa 100644 --- a/journals/2026_10_06.md +++ b/journals/2026_10_06.md @@ -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]] \ No newline at end of file + - [[GitP/A/Log/26/10/05 Mon - simplify gitpa garden proxies]] + - [[GitP/How To/Prepare Podcast Metadata]] \ No newline at end of file diff --git a/mise-tasks/logseq/entity/proxy/page/lib/core.py b/mise-tasks/logseq/entity/proxy/page/lib/core.py index 6c2c0beed..534410d93 100644 --- a/mise-tasks/logseq/entity/proxy/page/lib/core.py +++ b/mise-tasks/logseq/entity/proxy/page/lib/core.py @@ -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)?$') @@ -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: @@ -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}') @@ -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)} @@ -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: @@ -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 diff --git a/mise-tasks/logseq/entity/proxy/page/sync b/mise-tasks/logseq/entity/proxy/page/sync index 0e341fc19..0a9de95f2 100755 --- a/mise-tasks/logseq/entity/proxy/page/sync +++ b/mise-tasks/logseq/entity/proxy/page/sync @@ -5,6 +5,7 @@ #USAGE flag "--destination " required=#true help="Destination graph root" #USAGE flag "--page " help="Exact logical page name" #USAGE flag "--codeforge-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]].""" @@ -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:]) @@ -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: @@ -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: diff --git a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py index a67aa7b19..629ab6ec0 100644 --- a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py +++ b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py @@ -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()) @@ -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') diff --git a/pages/GitP___How To___Prepare Podcast Metadata.md b/pages/GitP___How To___Prepare Podcast Metadata.md index 41cbefe63..771795ebc 100644 --- a/pages/GitP___How To___Prepare Podcast Metadata.md +++ b/pages/GitP___How To___Prepare Podcast Metadata.md @@ -1,5 +1,5 @@ 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 @@ -7,8 +7,8 @@ see-also:: [[GitP/mise/Task/episode/sync]], [[GitP/How To/Draft an Episode from - 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. diff --git a/pages/GitP___mise___Task___episode___sync.md b/pages/GitP___mise___Task___episode___sync.md deleted file mode 100644 index 6b2c71010..000000000 --- a/pages/GitP___mise___Task___episode___sync.md +++ /dev/null @@ -1,27 +0,0 @@ -logseq-entity:: [[Logseq/Entity/Mise/Task]] -task-owner:: [[Person/codekiln/GitHub/gitpa]] -task-config-root:: . -task-name:: episode:sync -source-link:: https://github.com/codekiln/gitpa/blob/main/mise-tasks/episode/sync -see-also:: [[GitP/How To/Prepare Podcast Metadata]] -- # Sync an Episode into Gitpa - - Mirrors a garden session and its asset pages into Gitpa as [[Logseq/Entity/Proxy/Page]] instances. Run it after preparing or editing an episode in this garden. - - ## Invocation - - From the Gitpa checkout: - - ~~~sh - mise run episode:sync -- --source /path/to/logseq-encode-garden 'GitP/A/Session/26/09/24-Thu' - ~~~ - - Use `--source` to select a review worktree when preparing a paired garden/Gitpa PR. - - ## Inputs and outputs - - The source session lives under `pages/`. Its embedded recording and linked asset pages are copied with identical filenames into Gitpa's `gitp-garden/pages/`. - - Each copy carries the source Logseq URL, code-forge URL, and sync date. Relative source assets are copied to matching destination paths. - - ## Side effects - - A new proxy starts with the source properties and body. Resync replaces the body and proxy properties while preserving Gitpa's other properties, including tags and publication identity. - - A destination page with the same name that is not a proxy stops the sync. Review that collision before changing either page. - - Public remote asset links remain links; this task uploads no media and publishes no website. - - ## Dependencies and recovery - - Mise supplies Python. Both repository checkouts must be available locally; source preparation and uploads happen before sync. - - Check reported missing source assets and repair their links or files. Run the sync again after updating the garden episode. - - ## Source and help - - [Episode sync task](https://github.com/codekiln/gitpa/blob/main/mise-tasks/episode/sync) - - `mise run episode:sync --help` diff --git a/pages/Logseq___Entity___Definition.md b/pages/Logseq___Entity___Definition.md index 4ebdeb799..c41bffc91 100644 --- a/pages/Logseq___Entity___Definition.md +++ b/pages/Logseq___Entity___Definition.md @@ -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. \ No newline at end of file diff --git a/pages/Logseq___Entity___Mise___Task.md b/pages/Logseq___Entity___Mise___Task.md index 1785e0546..cf4b531f7 100644 --- a/pages/Logseq___Entity___Mise___Task.md +++ b/pages/Logseq___Entity___Mise___Task.md @@ -8,7 +8,7 @@ see-also:: [[mise/Task]], [[Logseq/Entity/CLI/Command]], [[Logseq/Entity/Diataxi - 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/`. 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. diff --git a/pages/Logseq___Entity___Podcast___Episode.md b/pages/Logseq___Entity___Podcast___Episode.md index 7925007ec..c3087d227 100644 --- a/pages/Logseq___Entity___Podcast___Episode.md +++ b/pages/Logseq___Entity___Podcast___Episode.md @@ -1,4 +1,5 @@ logseq-entity:: [[Logseq/Entity/Definition]] +entity-proxy-destination-properties:: podcast-guid, podcast-published-at - # Podcast Episode - In this garden, **Podcast Episode** pages model one episode of a [[Logseq/Entity/Podcast]], captured as listening notes or a recording prepared for publication. @@ -25,7 +26,7 @@ logseq-entity:: [[Logseq/Entity/Definition]] - 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 diff --git a/pages/Logseq___Entity___Proxy___Page.md b/pages/Logseq___Entity___Proxy___Page.md index 57fc7a460..399dda663 100644 --- a/pages/Logseq___Entity___Proxy___Page.md +++ b/pages/Logseq___Entity___Proxy___Page.md @@ -1,6 +1,7 @@ logseq-entity:: [[Logseq/Entity/Definition]] alias:: [[Proxy Page]], [[Logseq Proxy Page]] entity-tasks:: [[Logseq/Entity/Proxy/Page/mise/Task/sync]] +entity-proxy-destination-properties:: public - # Proxy Page - In this garden, **Proxy Page** marks a page whose body is mirrored from a page in another [[Logseq/Garden]], so one garden can read a page that lives in another without that page being moved or duplicated by hand. See [[Logseq/Idea/Proxy]] for the motivation. - ## What counts as an instance @@ -22,7 +23,9 @@ entity-tasks:: [[Logseq/Entity/Proxy/Page/mise/Task/sync]] - No shape of its own. The body is the source page's body, so the source's shape — and the shape of whatever entity the page is primarily — governs. - ## Invariants - **`tags::` on an existing page is never touched.** On re-sync the destination's `tags::` win outright and the source's are discarded. On first create, the source's `tags::` come across with the copy unless the user says otherwise. - - **Destination properties survive a re-sync.** Only the proxy keys are rewritten; every other property line already on the page stays. The **body**, by contrast, is replaced wholesale by the source's. + - **The source owns the properties, except those the destination owns.** A re-sync adds properties the source gained since the last sync, updates those whose value changed, and removes those the source no longer has. The **body** is replaced wholesale by the source's. + - **The destination owns** `tags::`, the proxy keys, and every property an entity definition names in `entity-proxy-destination-properties::`. The definitions consulted are the ones in the source page's `logseq-entity::`, plus this one, which names `public::`: whether a page is published is the destination garden's decision. A re-sync keeps the destination's value of an owned property and never takes the source's, including when the destination has none. + - **A property only the destination has goes on re-sync** unless the destination owns it. A garden that wants to keep a local property declares it on the entity definition. The preview names every property it adds, updates or removes. - **A name collision is never resolved silently.** If the destination page exists and has no `logseq-proxy-url::`, it is a real page that happens to share the name — stop and ask before writing anything. - ## Finding and deduplicating - Proxies scatter across `pages/` wherever their mirrored names fall, so there is no namespace to narrow a search to. Always search repo-wide from the garden root. @@ -41,17 +44,18 @@ entity-tasks:: [[Logseq/Entity/Proxy/Page/mise/Task/sync]] - ## Syncing an instance - **Source file:** the file the code forge URL names, or else `pages/.md` under the graph root, with `page=` decoded (`%2F` is `/`) and each `/` written as `___`. - **First copy:** the whole source file, with the proxy keys set in its property block. - - **Re-sync:** the source's body under the destination's property block, with the proxy keys rewritten, per **Invariants** above. + - **Re-sync:** the source's body under a property block merged per **Invariants** above. Properties already on the destination keep their order; properties new from the source follow them, and the proxy keys come last. - **Property block:** the run of `key:: value` lines from the top of the file, ending at the first line of any other kind — a blank line, a `- # Heading`, or plain text. A destination with no property block gains one above its first line, holding the proxy keys. - **Assets:** every `../assets/` the source body links to is copied to the same `assets/` here, replacing what is there. A missing source asset is reported and the sync goes on. - - **Report:** the source path, the destination path, the proxy key values written, whether it was a first copy or a re-sync, and the assets copied. + - **Embeds:** with `--follow-embeds`, each page embedded with the `embed` macro is synced as a proxy too, recursively, so a page and the pages it embeds sync in one batch. A missing embedded page is reported and the sync goes on. + - **Report:** the source path, the destination path, the proxy key values written, whether it was a first copy or a re-sync, the properties added, updated or removed, and the assets copied. - ## Executable sync - [[Logseq/Entity/Proxy/Page/mise/Task/sync]] previews the page, entity definitions, companion task references, implementation files, and assets imported by a sync. `--apply` applies the previewed changes. - An instance's `logseq-entity::` links lead to its source entity definitions. Each definition's `entity-tasks::` declares companion task references, whose file mappings and task dependencies follow [[Logseq/Entity/Mise/Task]]. The sync visits shared and cyclic dependencies once. - `.logseq-proxy/manifest.json` in the destination graph records source repository and graph root, logical page identity, source-to-destination file mappings, ownership, and last imported content hashes. - - The manifest retains the latest source companion declarations. When a proxy becomes the source for another garden, task discovery uses those declarations and resolves imported implementation paths through the source manifest. Destination page properties remain preserved during re-sync. + - The manifest retains the latest source companion declarations. When a proxy becomes the source for another garden, task discovery uses those declarations and resolves imported implementation paths through the source manifest. - Re-sync refreshes source changes where imported implementation files still match their recorded hashes. Local implementation edits, unowned destination files, and competing source claims produce conflicts before application. - - Destination page properties survive re-sync under the existing frontmatter rules; source page bodies refresh. Files removed from upstream task mappings are reported as cleanup candidates and retained locally. + - Page properties and bodies refresh from the source per **Invariants** above. Files removed from upstream task mappings are reported as cleanup candidates and retained locally. - A preview reports logical entity definitions without files separately from missing declared tasks or implementation files. Missing source assets remain warnings; missing task requirements stop application. - ## Examples in this garden - [[Book/ML with PyTorch and Scikit-Learn]] diff --git a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md index ad793eadc..35dd06875 100644 --- a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md +++ b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md @@ -18,14 +18,19 @@ see-also:: [[Logseq/Entity/Proxy/Page]], [[Logseq/Entity/Definition]] mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination /path/to/other-garden --page 'Logseq/Entity/Proxy/Page' --apply ~~~ - `--page` is the source page's logical name, with slash namespaces. On re-sync, `--source` can be omitted when the destination page's proxy metadata resolves to a local source graph. + - `--follow-embeds` also syncs every page the page embeds with the `embed` macro, and the pages those embed, in the same batch. A podcast session and its embedded recording, artwork and preset pages sync together this way: + - ~~~sh + mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination /path/to/other-garden --page 'GitP/A/Session/26/09/24-Thu' --follow-embeds --apply + ~~~ - ## Inputs and outputs - Reads the source page and the entity definitions named by `logseq-entity::`. Definitions declare companion tasks with `entity-tasks::`; task references declare implementation paths and required task dependencies through [[Logseq/Entity/Mise/Task]]. - - Copies pages under their source logical names, adds [[Logseq/Entity/Proxy/Page]] membership, and sets source URL, available code forge URL, and sync date. Existing destination page properties, including `tags::`, survive re-sync; source bodies replace destination bodies. + - Copies pages under their source logical names, adds [[Logseq/Entity/Proxy/Page]] membership, and sets source URL, available code forge URL, and sync date. Source bodies replace destination bodies. + - On re-sync, page properties follow the source: new ones are added, changed ones updated, and ones the source dropped are removed. `tags::`, the proxy keys, and the properties named by an entity definition's `entity-proxy-destination-properties::` keep their destination values. [[Logseq/Entity/Proxy/Page]] states the rule; the preview lists each property added, updated or removed. - Copies linked `../assets/` files and explicitly mapped task files. Source implementation paths start at the repository root, even when the source graph occupies a subdirectory; destination paths start at the destination graph root. - Records imported ownership, source paths, destination paths, and last imported hashes in `.logseq-proxy/manifest.json`. A repeat run previews only the changes still needed. - - The manifest keeps current source task declarations and path mappings for onward imports from a proxy garden. These declarations track upstream task changes while existing destination frontmatter remains intact. + - The manifest keeps current source task declarations and path mappings for onward imports from a proxy garden. - ## Side effects - - Preview reads local files and reports planned changes, conflicts, missing assets, and upstream removal candidates. `--apply` creates or refreshes the previewed imports and manifest after validation. + - Preview reads local files and reports planned changes, property changes, conflicts, missing assets and embedded pages, and upstream removal candidates. `--apply` creates or refreshes the previewed imports and manifest after validation. - An existing page without proxy metadata is a name collision. A task file with local edits, an unowned file at the target path, or competing source ownership stops application. Destination edits remain available for review. - Files removed from upstream task mappings remain in the destination and appear as cleanup candidates. Missing linked source assets are warnings; missing declared task requirements stop application. - Application stages writes and retains recovery information or restores previous files if a write fails.