contextbase-plugin-workflowy 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: contextbase-plugin-workflowy
3
+ Version: 0.2.3
4
+ Summary: Workflowy plugin for ContextBase
5
+ Author: Alizain Feerasta
6
+ Author-email: Alizain Feerasta <alizain.feerasta@gmail.com>
7
+ Requires-Dist: contextbase-shared-plugins==0.2.3
8
+ Requires-Dist: dagster==1.12.14
9
+ Requires-Dist: dagster-dlt==0.28.14
10
+ Requires-Dist: dlt>=1.26.0
11
+ Requires-Dist: pydantic>=2.12.0
12
+ Requires-Python: >=3.14, <3.15
@@ -0,0 +1,15 @@
1
+ plugin_workflowy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ plugin_workflowy/binding_config.py,sha256=asvUwjEKy1SEizvIenPckWIhRuTMwqIhgxjwif46ozs,160
3
+ plugin_workflowy/component.py,sha256=oB9mnQydO9CrZSK6QKzbQiAy7THzf20Ejq65RivgAbQ,3584
4
+ plugin_workflowy/defs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ plugin_workflowy/defs/defs.yaml,sha256=RFaHPrv3Xo84AZsLb18TrDEOLfpKMsX7axl-oRrYaT4,56
6
+ plugin_workflowy/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ plugin_workflowy/models/ctx.py,sha256=BcRgt9VDa6JojYHO0Bnn0Ql-XOxgdXez9RwE_P3QyJM,543
8
+ plugin_workflowy/models/ingress.py,sha256=pb_hXjJaslOFE3WrNx-iA6At5PEN7XT6Ufyzemaa2R0,1477
9
+ plugin_workflowy/models/translators.py,sha256=QKlStO_VP9lqJ73bq70maJl5VHtOU8Ok6tB6Dylmp0s,3228
10
+ plugin_workflowy/plugin.json,sha256=QLcgMi80sLJy2UbjY7w96GQ3hXV1roRICFYgoyWIWA0,85
11
+ plugin_workflowy/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ plugin_workflowy/sources/snapshot.py,sha256=VHLh-84QLtWgQlyjrmjygs168a8UqJjtCSWHseg1kjY,4337
13
+ contextbase_plugin_workflowy-0.2.3.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
14
+ contextbase_plugin_workflowy-0.2.3.dist-info/METADATA,sha256=X1ITkMjM3yjgF1-TTTOY4fr3W1RjW1Z2JS5VSAce3oQ,406
15
+ contextbase_plugin_workflowy-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
File without changes
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from shared_plugins.bindings import BaseBindingConfigModel
4
+
5
+
6
+ class WorkflowyBindingConfig(BaseBindingConfigModel):
7
+ pass
@@ -0,0 +1,104 @@
1
+ import dagster as dg
2
+ from dagster import AssetExecutionContext
3
+ from dagster_dlt import DagsterDltResource
4
+ from shared_plugins.automation import non_overlapping_automation_condition
5
+ from shared_plugins.bindings import (
6
+ parse_binding_config,
7
+ require_api_key,
8
+ )
9
+ from shared_plugins.control_plane import ControlPlaneClient
10
+ from shared_plugins.dlt import resolve_partition_binding, run_dlt_pipeline
11
+ from shared_plugins.naming import (
12
+ dagster_asset_group_name,
13
+ dagster_asset_tags,
14
+ dagster_dlt_asset_key,
15
+ dagster_partition_def_name,
16
+ dagster_pool_name,
17
+ dlt_source_name,
18
+ plugin_id_from_module,
19
+ )
20
+ from shared_plugins.resources import DLT_RESOURCE
21
+
22
+ from .binding_config import WorkflowyBindingConfig
23
+ from .sources.snapshot import workflowy_snapshot_source
24
+
25
+ PLUGIN_ID = plugin_id_from_module(__file__)
26
+ SNAPSHOT_JOB = "snapshot"
27
+ SNAPSHOT_SOURCE_NAME = dlt_source_name(PLUGIN_ID, SNAPSHOT_JOB)
28
+ SNAPSHOT_NODES_ASSET_KEY = dagster_dlt_asset_key(SNAPSHOT_SOURCE_NAME, "nodes")
29
+
30
+
31
+ def _build_snapshot_specs(
32
+ partitions_def: dg.PartitionsDefinition,
33
+ automation_condition: dg.AutomationCondition,
34
+ ) -> list[dg.AssetSpec]:
35
+ return [
36
+ dg.AssetSpec(
37
+ key=SNAPSHOT_NODES_ASSET_KEY,
38
+ group_name=dagster_asset_group_name(PLUGIN_ID),
39
+ tags=dagster_asset_tags(PLUGIN_ID),
40
+ automation_condition=automation_condition,
41
+ partitions_def=partitions_def,
42
+ )
43
+ ]
44
+
45
+
46
+ class WorkflowySyncComponent(dg.Component):
47
+ def build_defs(self, context: dg.ComponentLoadContext) -> dg.Definitions:
48
+ partitions_def = dg.DynamicPartitionsDefinition(
49
+ name=dagster_partition_def_name(PLUGIN_ID)
50
+ )
51
+ snapshot_specs = _build_snapshot_specs(
52
+ partitions_def,
53
+ non_overlapping_automation_condition(
54
+ dg.AutomationCondition.on_missing()
55
+ | dg.AutomationCondition.on_cron("*/15 * * * *")
56
+ ),
57
+ )
58
+
59
+ @dg.multi_asset(
60
+ specs=snapshot_specs,
61
+ can_subset=True,
62
+ name="workflowy_snapshot",
63
+ pool=dagster_pool_name(PLUGIN_ID),
64
+ )
65
+ def workflowy_snapshot_assets(
66
+ context: AssetExecutionContext,
67
+ dlt_resource: DagsterDltResource,
68
+ control_plane: dg.ResourceParam[ControlPlaneClient],
69
+ ):
70
+ binding = resolve_partition_binding(
71
+ context=context,
72
+ control_plane=control_plane,
73
+ plugin_id=PLUGIN_ID,
74
+ )
75
+ binding_id = str(binding.binding_id)
76
+ parse_binding_config(binding, WorkflowyBindingConfig)
77
+
78
+ source = workflowy_snapshot_source(
79
+ binding_id,
80
+ api_key=require_api_key(binding).api_key,
81
+ )
82
+ yield from run_dlt_pipeline(
83
+ context=context,
84
+ dlt_resource=dlt_resource,
85
+ source=source,
86
+ plugin_id=PLUGIN_ID,
87
+ binding_id=binding_id,
88
+ job_name=SNAPSHOT_JOB,
89
+ )
90
+
91
+ automation_sensor = dg.AutomationConditionSensorDefinition(
92
+ name="workflowy_automation_sensor",
93
+ target=dg.AssetSelection.assets(workflowy_snapshot_assets),
94
+ default_status=dg.DefaultSensorStatus.RUNNING,
95
+ minimum_interval_seconds=30,
96
+ )
97
+
98
+ return dg.Definitions(
99
+ assets=[workflowy_snapshot_assets],
100
+ sensors=[automation_sensor],
101
+ resources={
102
+ "dlt_resource": DLT_RESOURCE,
103
+ },
104
+ )
File without changes
@@ -0,0 +1 @@
1
+ type: plugin_workflowy.component.WorkflowySyncComponent
File without changes
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import AwareDatetime
6
+ from shared_plugins.models import CtxModel, IdStr
7
+
8
+
9
+ class WorkflowyNodeRow(CtxModel):
10
+ id: IdStr
11
+ name: str = ""
12
+ note: str | None = None
13
+ parent_id: str | None = None
14
+ depth: int = 0
15
+ breadcrumb: str = ""
16
+ created_at: AwareDatetime | None = None
17
+ modified_at: AwareDatetime | None = None
18
+ completed_at: AwareDatetime | None = None
19
+ completed: bool = False
20
+ priority: int = 0
21
+ data: dict[str, Any] | None = None
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import (
4
+ AwareDatetime,
5
+ AliasChoices,
6
+ Field,
7
+ model_validator,
8
+ )
9
+ from shared_plugins.models import IdStr, IngressModel
10
+
11
+
12
+ class WorkflowyNodeIngress(IngressModel):
13
+ id: IdStr
14
+
15
+ name: str = Field(default="", strict=True)
16
+ note: str | None = None
17
+ parent_id: str | None = Field(
18
+ default=None,
19
+ validation_alias=AliasChoices(
20
+ "parent_id", "parentId", "parentID", "parent", "parent-id"
21
+ ),
22
+ )
23
+ priority: int = Field(
24
+ default=0,
25
+ strict=True,
26
+ validation_alias=AliasChoices("priority", "priority_id", "priorityId"),
27
+ )
28
+ created_at: AwareDatetime | None = Field(
29
+ default=None,
30
+ validation_alias=AliasChoices("createdAt", "created_at"),
31
+ )
32
+ modified_at: AwareDatetime | None = Field(
33
+ default=None,
34
+ validation_alias=AliasChoices("modifiedAt", "modified_at"),
35
+ )
36
+ completed_at: AwareDatetime | None = Field(
37
+ default=None,
38
+ validation_alias=AliasChoices("completedAt", "completed_at"),
39
+ )
40
+ completed: bool = Field(
41
+ default=False,
42
+ strict=True,
43
+ validation_alias=AliasChoices("completed", "is_completed", "isCompleted"),
44
+ )
45
+ data: dict[str, object] | None = None
46
+
47
+ @model_validator(mode="after")
48
+ def _derive_completed(self) -> WorkflowyNodeIngress:
49
+ if self.completed_at is not None:
50
+ self.completed = True
51
+ return self
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Iterable, Iterator
5
+
6
+ from .ctx import WorkflowyNodeRow
7
+ from .ingress import WorkflowyNodeIngress
8
+
9
+ # Keep these as module constants so behavior is explicit and easy to adjust.
10
+ INCLUDE_COMPLETED = True
11
+ BREADCRUMB_SEGMENT_MAX_LEN = 100
12
+ BREADCRUMB_SEPARATOR = " > "
13
+
14
+
15
+ def _completed_subtree_ids(
16
+ nodes: list[WorkflowyNodeIngress],
17
+ ) -> set[str]:
18
+ """Return IDs of all completed nodes and every descendant beneath them."""
19
+ completed_ids = {n.id for n in nodes if n.completed}
20
+ if not completed_ids:
21
+ return set()
22
+
23
+ children_of: dict[str | None, list[str]] = defaultdict(list)
24
+ for n in nodes:
25
+ children_of[n.parent_id].append(n.id)
26
+
27
+ excluded: set[str] = set()
28
+ stack = list(completed_ids)
29
+ while stack:
30
+ node_id = stack.pop()
31
+ if node_id in excluded:
32
+ continue
33
+ excluded.add(node_id)
34
+ stack.extend(children_of.get(node_id, ()))
35
+
36
+ return excluded
37
+
38
+
39
+ def _truncate(text: str, max_len: int) -> str:
40
+ if len(text) <= max_len:
41
+ return text
42
+ return text[: max_len - 1] + "\u2026"
43
+
44
+
45
+ def _compute_tree_attrs(
46
+ nodes_by_id: dict[str, WorkflowyNodeIngress],
47
+ ) -> dict[str, tuple[int, str]]:
48
+ """Compute (depth, breadcrumb) for every node. Caches results to avoid
49
+ repeated ancestor walks."""
50
+ cache: dict[str, tuple[int, str]] = {}
51
+
52
+ def resolve(node_id: str) -> tuple[int, str]:
53
+ if node_id in cache:
54
+ return cache[node_id]
55
+
56
+ node = nodes_by_id[node_id]
57
+ segment = _truncate(node.name, BREADCRUMB_SEGMENT_MAX_LEN)
58
+
59
+ if node.parent_id is None or node.parent_id not in nodes_by_id:
60
+ result = (0, segment)
61
+ else:
62
+ parent_depth, parent_breadcrumb = resolve(node.parent_id)
63
+ result = (
64
+ parent_depth + 1,
65
+ parent_breadcrumb + BREADCRUMB_SEPARATOR + segment,
66
+ )
67
+
68
+ cache[node_id] = result
69
+ return result
70
+
71
+ for node_id in nodes_by_id:
72
+ resolve(node_id)
73
+
74
+ return cache
75
+
76
+
77
+ def workflowy_nodes_to_ctx_models(
78
+ *,
79
+ binding_id: str,
80
+ nodes: Iterable[WorkflowyNodeIngress],
81
+ include_completed: bool = INCLUDE_COMPLETED,
82
+ ) -> Iterator[WorkflowyNodeRow]:
83
+ all_nodes = list(nodes)
84
+
85
+ if include_completed:
86
+ excluded: set[str] = set()
87
+ else:
88
+ excluded = _completed_subtree_ids(all_nodes)
89
+
90
+ live_nodes = [n for n in all_nodes if n.id not in excluded]
91
+ nodes_by_id = {n.id: n for n in live_nodes}
92
+ tree_attrs = _compute_tree_attrs(nodes_by_id)
93
+
94
+ for node in live_nodes:
95
+ depth, breadcrumb = tree_attrs[node.id]
96
+
97
+ yield WorkflowyNodeRow(
98
+ ctx_binding_id=binding_id,
99
+ ctx_source_updated_at=node.modified_at,
100
+ id=node.id,
101
+ name=node.name,
102
+ note=node.note,
103
+ parent_id=node.parent_id,
104
+ depth=depth,
105
+ breadcrumb=breadcrumb,
106
+ created_at=node.created_at,
107
+ modified_at=node.modified_at,
108
+ completed_at=node.completed_at,
109
+ completed=node.completed,
110
+ priority=node.priority,
111
+ data=node.data,
112
+ )
@@ -0,0 +1,7 @@
1
+ {
2
+ "auth": {
3
+ "type": "api_key"
4
+ },
5
+ "mode": "dagster",
6
+ "plugin_id": "workflowy"
7
+ }
File without changes
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable
5
+ from collections.abc import Iterator
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ import dlt
10
+ from dlt.sources.rest_api import rest_api_resources
11
+ from requests import Response
12
+ from shared_plugins.naming import (
13
+ dlt_resource_name,
14
+ dlt_source_name,
15
+ plugin_id_from_module,
16
+ )
17
+ from shared_plugins.resources import ctx_dlt_resource
18
+
19
+ from ..models.ctx import WorkflowyNodeRow
20
+ from ..models.ingress import WorkflowyNodeIngress
21
+ from ..models.translators import workflowy_nodes_to_ctx_models
22
+
23
+ PLUGIN_ID = plugin_id_from_module(__file__)
24
+ JOB = "snapshot"
25
+ WORKFLOWY_BASE_URL = "https://beta.workflowy.com/api/v1"
26
+ WORKFLOWY_EXPORT_PATH = "nodes-export"
27
+ DEFAULT_API_KEY_HEADER = "X-Workflowy-Api-Key"
28
+
29
+
30
+ def _extract_nodes_from_export_payload(payload: object) -> list[Mapping[str, Any]]:
31
+ if isinstance(payload, list):
32
+ raw_nodes = payload
33
+ elif isinstance(payload, dict):
34
+ if isinstance(payload.get("items"), list):
35
+ raw_nodes = payload["items"]
36
+ elif isinstance(payload.get("nodes"), list):
37
+ raw_nodes = payload["nodes"]
38
+ elif isinstance(payload.get("data"), list):
39
+ raw_nodes = payload["data"]
40
+ else:
41
+ raise RuntimeError(
42
+ "Workflowy export response did not include a nodes list under items/nodes/data."
43
+ )
44
+ else:
45
+ raise RuntimeError(
46
+ "Workflowy export response was neither a JSON array nor object."
47
+ )
48
+
49
+ extracted: list[Mapping[str, Any]] = []
50
+ for entry in raw_nodes:
51
+ if not isinstance(entry, Mapping):
52
+ raise RuntimeError(
53
+ "Workflowy export nodes list contained a non-object entry."
54
+ )
55
+ extracted.append(entry)
56
+ return extracted
57
+
58
+
59
+ def _canonicalize_workflowy_export_response(
60
+ response: Response,
61
+ *args: object,
62
+ **kwargs: object,
63
+ ) -> None:
64
+ if response.status_code >= 400:
65
+ return
66
+
67
+ try:
68
+ payload = response.json()
69
+ except ValueError as exc:
70
+ raise RuntimeError("Workflowy export returned invalid JSON.") from exc
71
+
72
+ nodes = _extract_nodes_from_export_payload(payload)
73
+ response._content = json.dumps({"nodes": nodes}).encode("utf-8")
74
+
75
+
76
+ def build_workflowy_export_nodes_resource(*, api_key: str) -> Any:
77
+ normalized_api_key = api_key.strip()
78
+ if not normalized_api_key:
79
+ raise RuntimeError("Workflowy API key cannot be empty.")
80
+
81
+ return rest_api_resources(
82
+ {
83
+ "client": {
84
+ "base_url": WORKFLOWY_BASE_URL,
85
+ "auth": {
86
+ "type": "bearer",
87
+ "token": normalized_api_key,
88
+ },
89
+ "headers": {
90
+ "Accept": "application/json",
91
+ DEFAULT_API_KEY_HEADER: normalized_api_key,
92
+ },
93
+ },
94
+ "resources": [
95
+ {
96
+ "name": "workflowy_export_nodes",
97
+ "selected": False,
98
+ "endpoint": {
99
+ "path": WORKFLOWY_EXPORT_PATH,
100
+ "method": "GET",
101
+ "paginator": "single_page",
102
+ "data_selector": "nodes",
103
+ "response_actions": [_canonicalize_workflowy_export_response],
104
+ },
105
+ }
106
+ ],
107
+ }
108
+ )[0]
109
+
110
+
111
+ def _ingress_nodes_from_raw(
112
+ raw_nodes: Iterable[Mapping[str, Any]],
113
+ ) -> Iterator[WorkflowyNodeIngress]:
114
+ for raw_node in raw_nodes:
115
+ yield WorkflowyNodeIngress.model_validate(raw_node)
116
+
117
+
118
+ @dlt.source(name=dlt_source_name(PLUGIN_ID, JOB))
119
+ def workflowy_snapshot_source(
120
+ binding_id: str,
121
+ *,
122
+ api_key: str,
123
+ ) -> tuple[Any, ...]:
124
+ raw_nodes_resource = build_workflowy_export_nodes_resource(api_key=api_key)
125
+
126
+ @ctx_dlt_resource(
127
+ name=dlt_resource_name("nodes"),
128
+ write_disposition="merge",
129
+ primary_key=("_ctx_binding_id", "id"),
130
+ )
131
+ def workflowy_nodes_resource() -> Iterator[WorkflowyNodeRow]:
132
+ ingress_nodes = _ingress_nodes_from_raw(raw_nodes_resource)
133
+ yield from workflowy_nodes_to_ctx_models(
134
+ binding_id=binding_id,
135
+ nodes=ingress_nodes,
136
+ )
137
+
138
+ return (workflowy_nodes_resource,)