woods 1.6.1 → 2.0.0.beta2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -12,34 +12,63 @@ module Woods
|
|
|
12
12
|
#
|
|
13
13
|
# @example
|
|
14
14
|
# mapper = ColumnMapper.new
|
|
15
|
-
# properties = mapper.map(column, model_identifier: "User", validations: [...],
|
|
15
|
+
# properties = mapper.map(column, model_identifier: "User", validations: [...], parent_page_ids: ["page-123"])
|
|
16
16
|
#
|
|
17
17
|
class ColumnMapper
|
|
18
18
|
include Shared
|
|
19
19
|
|
|
20
20
|
# Map a single column to Notion Columns page properties.
|
|
21
21
|
#
|
|
22
|
+
# The page title is qualified as "<table>.<column>" (e.g. "users.id")
|
|
23
|
+
# when a table name is given. Every Rails model shares id/created_at/
|
|
24
|
+
# updated_at, and the Columns database is upserted by title — a bare
|
|
25
|
+
# column-name title made every model overwrite every other model's
|
|
26
|
+
# shared-name column pages (#149). The qualifier must come from
|
|
27
|
+
# {ModelMapper.table_name_for} so it agrees with the Data Models page
|
|
28
|
+
# the Table relation points at.
|
|
29
|
+
#
|
|
30
|
+
# A physical table can be owned by several models (STI, or a shared
|
|
31
|
+
# +self.table_name=+), and the column page is one page per physical
|
|
32
|
+
# column — so the Table relation lists *every* owner's Data Models
|
|
33
|
+
# page. A single-owner relation made the two models fight over the
|
|
34
|
+
# page's parent claim on every run (EXP-1); Notion relations are lists,
|
|
35
|
+
# so naming them all is both accurate and stable.
|
|
36
|
+
#
|
|
22
37
|
# @param column [Hash] Column hash from metadata["columns"] (name, type, null, default)
|
|
23
38
|
# @param model_identifier [String] Parent model name (for context)
|
|
39
|
+
# @param table_name [String, nil] Owning table name used to qualify the page title
|
|
24
40
|
# @param validations [Array<Hash>] Model-level validations to match against this column
|
|
25
|
-
# @param
|
|
41
|
+
# @param parent_page_ids [Array<String>] Notion page IDs of the owning
|
|
42
|
+
# Data Models pages, in a caller-fixed (deterministic) order
|
|
26
43
|
# @return [Hash] Notion page properties hash
|
|
27
|
-
def map(column, model_identifier: nil, validations: [],
|
|
44
|
+
def map(column, model_identifier: nil, table_name: nil, validations: [], parent_page_ids: []) # rubocop:disable Lint/UnusedMethodArgument
|
|
28
45
|
properties = {
|
|
29
|
-
'Column Name' => { title: [{ text: { content: column['name'] } }] },
|
|
46
|
+
'Column Name' => { title: [{ text: { content: qualified_title(column['name'], table_name) } }] },
|
|
30
47
|
'Data Type' => { select: { name: column['type'] } },
|
|
31
48
|
'Nullable' => { checkbox: column['null'] == true },
|
|
32
49
|
'Default Value' => rich_text_property(column['default'].to_s),
|
|
33
50
|
'Validation Rules' => rich_text_property(format_validation_rules(column['name'], validations))
|
|
34
51
|
}
|
|
35
52
|
|
|
36
|
-
|
|
53
|
+
related = Array(parent_page_ids).compact
|
|
54
|
+
properties['Table'] = { relation: related.map { |id| { id: id } } } if related.any?
|
|
37
55
|
|
|
38
56
|
properties
|
|
39
57
|
end
|
|
40
58
|
|
|
41
59
|
private
|
|
42
60
|
|
|
61
|
+
# Build the column page title, qualified by table when known.
|
|
62
|
+
#
|
|
63
|
+
# @param column_name [String]
|
|
64
|
+
# @param table_name [String, nil]
|
|
65
|
+
# @return [String] "<table>.<column>", or the bare name without a table
|
|
66
|
+
def qualified_title(column_name, table_name)
|
|
67
|
+
return column_name.to_s if table_name.nil? || table_name.to_s.empty?
|
|
68
|
+
|
|
69
|
+
"#{table_name}.#{column_name}"
|
|
70
|
+
end
|
|
71
|
+
|
|
43
72
|
# Find and format validations matching this column name.
|
|
44
73
|
#
|
|
45
74
|
# @param column_name [String]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module Notion
|
|
5
7
|
module Mappers
|
|
@@ -13,25 +15,47 @@ module Woods
|
|
|
13
15
|
# # => { "users" => "2026-02-20T10:00:00Z", "posts" => "2026-01-15T09:00:00Z" }
|
|
14
16
|
#
|
|
15
17
|
class MigrationMapper
|
|
16
|
-
#
|
|
18
|
+
# A migration filename stamp: 20260220100000.
|
|
19
|
+
VERSION_STAMP = /\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\z/
|
|
20
|
+
|
|
21
|
+
# Compute the latest schema-change date for each affected table.
|
|
22
|
+
#
|
|
23
|
+
# The date is the **migration's own** version stamp, not the unit's
|
|
24
|
+
# +extracted_at+ (EXP-3). `extracted_at` records when Woods ran, so a
|
|
25
|
+
# full extraction re-stamped every unit and made every table read
|
|
26
|
+
# "changed today" — and rewrote every Data Models page to say so.
|
|
27
|
+
# Units with no parseable version fall back to `extracted_at`.
|
|
17
28
|
#
|
|
18
29
|
# @param migration_units [Array<Hash>] Parsed migration ExtractedUnit JSONs
|
|
19
|
-
# @return [Hash<String, String>] Table name to latest
|
|
30
|
+
# @return [Hash<String, String>] Table name to latest ISO8601 timestamp
|
|
20
31
|
def latest_changes(migration_units)
|
|
21
32
|
migration_units.each_with_object({}) do |unit, changes|
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
metadata = unit['metadata'] || {}
|
|
34
|
+
changed_at = version_timestamp(metadata['migration_version']) || unit['extracted_at']
|
|
35
|
+
next unless changed_at
|
|
24
36
|
|
|
25
|
-
tables =
|
|
26
|
-
tables.each { |table| update_latest(changes, table,
|
|
37
|
+
tables = metadata['tables_affected'] || []
|
|
38
|
+
tables.each { |table| update_latest(changes, table, changed_at) }
|
|
27
39
|
end
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
private
|
|
31
43
|
|
|
44
|
+
# @param version [String, nil] a `%Y%m%d%H%M%S` migration stamp
|
|
45
|
+
# @return [String, nil] ISO8601 UTC, or nil when absent/unparseable
|
|
46
|
+
def version_timestamp(version)
|
|
47
|
+
match = VERSION_STAMP.match(version.to_s)
|
|
48
|
+
return nil unless match
|
|
49
|
+
|
|
50
|
+
Time.utc(*match.captures.map(&:to_i)).iso8601
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
# A well-shaped stamp naming an impossible date (month 13).
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
# @return [void]
|
|
33
|
-
def update_latest(changes, table,
|
|
34
|
-
changes[table] =
|
|
57
|
+
def update_latest(changes, table, changed_at)
|
|
58
|
+
changes[table] = changed_at if changes[table].nil? || changed_at > changes[table]
|
|
35
59
|
end
|
|
36
60
|
end
|
|
37
61
|
end
|
|
@@ -18,6 +18,24 @@ module Woods
|
|
|
18
18
|
class ModelMapper # rubocop:disable Metrics/ClassLength
|
|
19
19
|
include Shared
|
|
20
20
|
|
|
21
|
+
# Derive the table name for a model unit — the exact value the Data
|
|
22
|
+
# Models page title is built from. Shared with the Columns sync so the
|
|
23
|
+
# column title qualifier ("<table>.<column>") and the Table relation
|
|
24
|
+
# always agree on which table a column belongs to (#149).
|
|
25
|
+
#
|
|
26
|
+
# Falls back to a naive tableized identifier when extraction produced
|
|
27
|
+
# no table_name (close enough to ActiveRecord convention for a title).
|
|
28
|
+
#
|
|
29
|
+
# @param unit_data [Hash] Parsed model ExtractedUnit JSON
|
|
30
|
+
# @return [String]
|
|
31
|
+
def self.table_name_for(unit_data)
|
|
32
|
+
metadata = unit_data['metadata'] || {}
|
|
33
|
+
return metadata['table_name'] if metadata['table_name']
|
|
34
|
+
|
|
35
|
+
identifier = unit_data['identifier'] || ''
|
|
36
|
+
"#{identifier.split('::').last.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}s"
|
|
37
|
+
end
|
|
38
|
+
|
|
21
39
|
# Map a model unit to Notion Data Models page properties.
|
|
22
40
|
#
|
|
23
41
|
# @param unit_data [Hash] Parsed model ExtractedUnit JSON
|
|
@@ -35,7 +53,7 @@ module Woods
|
|
|
35
53
|
# @return [Hash] Text-based Notion properties
|
|
36
54
|
def build_text_properties(unit_data, metadata)
|
|
37
55
|
{
|
|
38
|
-
'Table Name' => title_property(table_name(unit_data
|
|
56
|
+
'Table Name' => title_property(table_name(unit_data)),
|
|
39
57
|
'Model Name' => rich_text_property(unit_data['identifier']),
|
|
40
58
|
'Description' => rich_text_property(extract_description(unit_data['source_code'])),
|
|
41
59
|
'Associations' => rich_text_property(format_associations(metadata['associations'])),
|
|
@@ -54,11 +72,8 @@ module Woods
|
|
|
54
72
|
end
|
|
55
73
|
|
|
56
74
|
# @return [String]
|
|
57
|
-
def table_name(unit_data
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
identifier = unit_data['identifier'] || ''
|
|
61
|
-
"#{identifier.split('::').last.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}s"
|
|
75
|
+
def table_name(unit_data)
|
|
76
|
+
self.class.table_name_for(unit_data)
|
|
62
77
|
end
|
|
63
78
|
|
|
64
79
|
# @return [Integer]
|
|
@@ -5,16 +5,58 @@ module Woods
|
|
|
5
5
|
module Mappers
|
|
6
6
|
# Shared helpers for Notion mapper classes.
|
|
7
7
|
module Shared
|
|
8
|
+
# Notion's rich_text content limit, counted in **UTF-16 code units**
|
|
9
|
+
# — not Ruby characters. A character outside the BMP (an emoji, most
|
|
10
|
+
# CJK extension blocks) costs two units, so a char-count check let
|
|
11
|
+
# payloads up to twice the limit through and the API rejected them
|
|
12
|
+
# with a 400 on every run (EXP-2).
|
|
8
13
|
MAX_RICH_TEXT_LENGTH = 2000
|
|
9
14
|
|
|
15
|
+
# Marks a truncated value; costs 3 UTF-16 units.
|
|
16
|
+
TRUNCATION_SUFFIX = '...'
|
|
17
|
+
|
|
10
18
|
# Build a Notion rich_text property, truncating to API limits.
|
|
11
19
|
#
|
|
12
20
|
# @param text [String]
|
|
13
21
|
# @return [Hash]
|
|
14
22
|
def rich_text_property(text)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
{ rich_text: [{ text: { content: truncate_to_utf16_limit(text.to_s) } }] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Truncate to {MAX_RICH_TEXT_LENGTH} UTF-16 code units, reserving room
|
|
29
|
+
# for {TRUNCATION_SUFFIX}.
|
|
30
|
+
#
|
|
31
|
+
# Walks whole characters, so a surrogate pair is never split — a lone
|
|
32
|
+
# surrogate is not valid UTF-8 and would fail the request differently.
|
|
33
|
+
#
|
|
34
|
+
# @param content [String]
|
|
35
|
+
# @return [String] the input unchanged when it already fits
|
|
36
|
+
def truncate_to_utf16_limit(content)
|
|
37
|
+
# UTF-8 never spends fewer bytes than UTF-16 spends units (1/2/3
|
|
38
|
+
# bytes for one unit, 4 for two), so this settles the common case
|
|
39
|
+
# without walking the string.
|
|
40
|
+
return content if content.bytesize <= MAX_RICH_TEXT_LENGTH
|
|
41
|
+
return content if utf16_length(content) <= MAX_RICH_TEXT_LENGTH
|
|
42
|
+
|
|
43
|
+
budget = MAX_RICH_TEXT_LENGTH - TRUNCATION_SUFFIX.length
|
|
44
|
+
used = 0
|
|
45
|
+
kept = +''
|
|
46
|
+
content.each_char do |char|
|
|
47
|
+
cost = char.ord >= 0x10000 ? 2 : 1
|
|
48
|
+
break if used + cost > budget
|
|
49
|
+
|
|
50
|
+
kept << char
|
|
51
|
+
used += cost
|
|
52
|
+
end
|
|
53
|
+
kept << TRUNCATION_SUFFIX
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param content [String]
|
|
57
|
+
# @return [Integer] length in UTF-16 code units
|
|
58
|
+
def utf16_length(content)
|
|
59
|
+
content.each_char.sum { |char| char.ord >= 0x10000 ? 2 : 1 }
|
|
18
60
|
end
|
|
19
61
|
end
|
|
20
62
|
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
require_relative '../atomic_file'
|
|
7
|
+
|
|
8
|
+
module Woods
|
|
9
|
+
module Notion
|
|
10
|
+
# Tracks what was last written to the Notion databases so a sync can
|
|
11
|
+
# skip unchanged pages entirely (zero API calls) and PATCH changed ones
|
|
12
|
+
# by cached page id (one call, no find-by-title query). Modeled on
|
|
13
|
+
# {Woods::Unblocked::SyncManifest} (#207 / B-095).
|
|
14
|
+
#
|
|
15
|
+
# Entries are grouped by scope — one scope per Notion database
|
|
16
|
+
# ("data_models", "columns") — and keyed by the page's logical identity:
|
|
17
|
+
# the qualified title from #149 ("users.id" for columns, the table name
|
|
18
|
+
# or "<table> (<Model>)" for data models). Each entry records the Notion
|
|
19
|
+
# page_id plus a deterministic content hash of the mapped properties.
|
|
20
|
+
#
|
|
21
|
+
# The manifest is a local cache, never the source of truth: a missing,
|
|
22
|
+
# corrupt, or unreadable file degrades to "everything is new" — a
|
|
23
|
+
# correct (if expensive) full title-lookup sync that rebuilds it. Reads
|
|
24
|
+
# and writes both go through {Woods::AtomicFile} from day one — a bare
|
|
25
|
+
# +File.read+ tags bytes with the process's default external encoding
|
|
26
|
+
# (US-ASCII under +LANG=C+), which broke the Unblocked manifest's
|
|
27
|
+
# degrade contract on non-ASCII content (B-077 / #189).
|
|
28
|
+
#
|
|
29
|
+
# A stored scope is discarded when its recorded database id no longer
|
|
30
|
+
# matches the configured one (the analog of Unblocked's collection
|
|
31
|
+
# guard): cached page_ids point into the *old* database, and a PATCH by
|
|
32
|
+
# page id would silently write pages the configured database never sees.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# manifest = SyncManifest.new(path: "tmp/woods/notion_sync_manifest.json",
|
|
36
|
+
# database_ids: { data_models: "db-uuid" })
|
|
37
|
+
# hash = SyncManifest.content_hash(properties)
|
|
38
|
+
# manifest.unchanged?("data_models", "users", hash) # => false on first run
|
|
39
|
+
# manifest.record(scope: "data_models", key: "users", hash: hash, page_id: "page-1")
|
|
40
|
+
# manifest.save
|
|
41
|
+
#
|
|
42
|
+
class SyncManifest
|
|
43
|
+
VERSION = 1
|
|
44
|
+
|
|
45
|
+
# Deterministic fingerprint of a mapped Notion properties payload.
|
|
46
|
+
#
|
|
47
|
+
# Hash keys are sorted recursively (after +to_s+, so a symbol-keyed and
|
|
48
|
+
# a string-keyed payload that serialize identically hash identically),
|
|
49
|
+
# so the digest never depends on insertion order. Array order is
|
|
50
|
+
# preserved — it is meaningful in Notion payloads (rich text runs,
|
|
51
|
+
# relation lists).
|
|
52
|
+
#
|
|
53
|
+
# @param properties [Hash] Mapper output (Notion page properties)
|
|
54
|
+
# @return [String] SHA-256 hex digest
|
|
55
|
+
def self.content_hash(properties)
|
|
56
|
+
Digest::SHA256.hexdigest(JSON.generate(canonicalize(properties)))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Recursively sort hash keys so serialization is order-independent.
|
|
60
|
+
#
|
|
61
|
+
# @api private
|
|
62
|
+
# @param value [Object]
|
|
63
|
+
# @return [Object]
|
|
64
|
+
def self.canonicalize(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash
|
|
67
|
+
value.map { |k, v| [k.to_s, canonicalize(v)] }.sort_by(&:first).to_h
|
|
68
|
+
when Array
|
|
69
|
+
value.map { |element| canonicalize(element) }
|
|
70
|
+
else
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param path [String] JSON file path for the manifest
|
|
76
|
+
# @param database_ids [Hash{Symbol,String=>String}, nil] Scope name =>
|
|
77
|
+
# Notion database UUID currently configured. A stored scope whose
|
|
78
|
+
# recorded database id differs is discarded on load.
|
|
79
|
+
def initialize(path:, database_ids:)
|
|
80
|
+
@path = path
|
|
81
|
+
@database_ids = normalize_database_ids(database_ids)
|
|
82
|
+
@pages = load
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Boolean] true when no pages are recorded in any scope
|
|
86
|
+
def empty?
|
|
87
|
+
@pages.values.all?(&:empty?)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Integer] number of recorded pages across all scopes
|
|
91
|
+
def size
|
|
92
|
+
@pages.values.sum(&:size)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param scope [String] Scope name (e.g. "data_models")
|
|
96
|
+
# @param key [String] Logical page key (qualified title)
|
|
97
|
+
# @param hash [String] Content hash of the properties we would write now
|
|
98
|
+
# @return [Boolean] true when the recorded hash matches *and* a page_id
|
|
99
|
+
# is on record (without one, a skip would leave dependent pages —
|
|
100
|
+
# column Table relations — unable to reference the page)
|
|
101
|
+
def unchanged?(scope, key, hash)
|
|
102
|
+
entry = @pages.dig(scope, key)
|
|
103
|
+
!entry.nil? && entry['hash'] == hash && !entry['page_id'].nil?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param scope [String] Scope name
|
|
107
|
+
# @param key [String] Logical page key
|
|
108
|
+
# @return [String, nil] Stored Notion page_id, if known
|
|
109
|
+
def page_id_for(scope, key)
|
|
110
|
+
@pages.dig(scope, key, 'page_id')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Record (or update) what we wrote for a page.
|
|
114
|
+
#
|
|
115
|
+
# @param scope [String] Scope name
|
|
116
|
+
# @param key [String] Logical page key (qualified title)
|
|
117
|
+
# @param hash [String] Content hash written
|
|
118
|
+
# @param page_id [String] Notion page UUID
|
|
119
|
+
# @return [void]
|
|
120
|
+
def record(scope:, key:, hash:, page_id:)
|
|
121
|
+
(@pages[scope] ||= {})[key] = { 'hash' => hash, 'page_id' => page_id }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Drop one key from a scope (e.g. after a cached page turned out to be
|
|
125
|
+
# deleted in Notion, so the next attempt goes through find-by-title).
|
|
126
|
+
#
|
|
127
|
+
# @param scope [String] Scope name
|
|
128
|
+
# @param key [String] Logical page key
|
|
129
|
+
# @return [void]
|
|
130
|
+
def forget(scope, key)
|
|
131
|
+
@pages[scope]&.delete(key)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Drop every key in +scope+ absent from +current_keys+ — pages whose
|
|
135
|
+
# logical identity vanished from the sync set. Only the manifest entry
|
|
136
|
+
# is dropped: the Notion page itself is deliberately left alone (there
|
|
137
|
+
# is no deletion path), pruning just keeps the manifest from growing
|
|
138
|
+
# forever. A key that later reappears goes through the find-by-title
|
|
139
|
+
# path and re-adopts the surviving page.
|
|
140
|
+
#
|
|
141
|
+
# @param scope [String] Scope name
|
|
142
|
+
# @param current_keys [Array<String>, Set] Keys that still exist this run
|
|
143
|
+
# @return [Array<String>] the pruned keys
|
|
144
|
+
def prune(scope, current_keys)
|
|
145
|
+
scoped = @pages[scope]
|
|
146
|
+
return [] unless scoped
|
|
147
|
+
|
|
148
|
+
stale = scoped.keys - current_keys.to_a
|
|
149
|
+
stale.each { |key| scoped.delete(key) }
|
|
150
|
+
stale
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Persist the manifest crash-safely ({Woods::AtomicFile} — temp file,
|
|
154
|
+
# fsync, rename) so an interrupted write never leaves a torn file.
|
|
155
|
+
#
|
|
156
|
+
# @return [void]
|
|
157
|
+
def save
|
|
158
|
+
payload = JSON.generate(
|
|
159
|
+
'version' => VERSION,
|
|
160
|
+
'databases' => @database_ids,
|
|
161
|
+
'pages' => @pages
|
|
162
|
+
)
|
|
163
|
+
AtomicFile.write(@path, payload)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# @param database_ids [Hash, nil]
|
|
169
|
+
# @return [Hash{String=>String}] string keys/values, nil values dropped
|
|
170
|
+
def normalize_database_ids(database_ids)
|
|
171
|
+
(database_ids || {}).each_with_object({}) do |(scope, id), normalized|
|
|
172
|
+
normalized[scope.to_s] = id.to_s unless id.nil?
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Load the persisted pages, discarding data from a different schema
|
|
177
|
+
# version or an unparseable/unreadable file, and discarding any scope
|
|
178
|
+
# recorded against a different database id. Every discard warns to
|
|
179
|
+
# stderr — the consequence (a full title-lookup re-check) is expensive
|
|
180
|
+
# enough that operators need to know why it happened.
|
|
181
|
+
#
|
|
182
|
+
# {Woods::AtomicFile.read}, not +File.read+ — see the class docs and
|
|
183
|
+
# B-077. Read failures (e.g. +Errno::EACCES+) take the same discard
|
|
184
|
+
# path: the manifest is a cache, and "everything is new" is always a
|
|
185
|
+
# correct answer.
|
|
186
|
+
#
|
|
187
|
+
# @return [Hash{String=>Hash}] scope => { key => { 'hash' =>, 'page_id' => } }
|
|
188
|
+
def load
|
|
189
|
+
return {} unless File.exist?(@path)
|
|
190
|
+
|
|
191
|
+
parsed = JSON.parse(AtomicFile.read(@path))
|
|
192
|
+
return discard('not a JSON object') unless parsed.is_a?(Hash)
|
|
193
|
+
return discard("schema version #{parsed['version'].inspect}, expected #{VERSION}") unless
|
|
194
|
+
parsed['version'] == VERSION
|
|
195
|
+
|
|
196
|
+
select_current_scopes(parsed)
|
|
197
|
+
rescue JSON::ParserError
|
|
198
|
+
discard('unparseable JSON')
|
|
199
|
+
rescue EncodingError, SystemCallError => e
|
|
200
|
+
discard("unreadable file: #{e.class}: #{e.message}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Keep only scopes whose recorded database id matches the configured
|
|
204
|
+
# one — cached page_ids from a different database must not be PATCHed.
|
|
205
|
+
#
|
|
206
|
+
# @param parsed [Hash] the persisted manifest document
|
|
207
|
+
# @return [Hash{String=>Hash}]
|
|
208
|
+
def select_current_scopes(parsed)
|
|
209
|
+
pages = parsed['pages']
|
|
210
|
+
return discard('malformed pages section') unless pages.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
stored_dbs = parsed['databases'].is_a?(Hash) ? parsed['databases'] : {}
|
|
213
|
+
pages.each_with_object({}) do |(scope, entries), kept|
|
|
214
|
+
scoped = load_scope(scope, entries, stored_dbs)
|
|
215
|
+
kept[scope] = scoped if scoped
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# One stored scope: kept (minus individually torn entries) when it was
|
|
220
|
+
# recorded against the configured database id, dropped with a warning
|
|
221
|
+
# otherwise.
|
|
222
|
+
#
|
|
223
|
+
# @param scope [String] Scope name from the persisted manifest
|
|
224
|
+
# @param entries [Object] Persisted entries for the scope
|
|
225
|
+
# @param stored_dbs [Hash] Persisted scope => database id map
|
|
226
|
+
# @return [Hash, nil] entries to keep, or nil to drop the scope
|
|
227
|
+
def load_scope(scope, entries, stored_dbs)
|
|
228
|
+
return nil unless entries.is_a?(Hash)
|
|
229
|
+
return entries.select { |_key, entry| entry.is_a?(Hash) } if current_scope?(scope, stored_dbs)
|
|
230
|
+
|
|
231
|
+
warn_scope_discard(scope, stored_dbs[scope]) unless entries.empty?
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @return [Boolean] true when the stored scope was recorded against the
|
|
236
|
+
# database id configured now (and one is configured at all)
|
|
237
|
+
def current_scope?(scope, stored_dbs)
|
|
238
|
+
!@database_ids[scope].nil? && stored_dbs[scope] == @database_ids[scope]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @param scope [String] Scope being discarded
|
|
242
|
+
# @param stored_id [String, nil] Database id the scope was recorded against
|
|
243
|
+
# @return [void]
|
|
244
|
+
def warn_scope_discard(scope, stored_id)
|
|
245
|
+
warn "WARNING: discarding notion sync manifest scope #{scope.inspect} at #{@path} (written for " \
|
|
246
|
+
"database #{stored_id.inspect}, expected #{@database_ids[scope].inspect}) — " \
|
|
247
|
+
'next sync re-checks those pages by title'
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @param reason [String] Why the persisted manifest is unusable
|
|
251
|
+
# @return [Hash] empty pages hash (degrades to a full title-lookup sync)
|
|
252
|
+
def discard(reason)
|
|
253
|
+
warn "WARNING: discarding notion sync manifest at #{@path} (#{reason}) — next sync re-checks every page"
|
|
254
|
+
{}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -7,5 +7,11 @@ module Woods
|
|
|
7
7
|
# Raised for recoverable Obsidian export failures (e.g. a missing extraction
|
|
8
8
|
# output dir). Inherits Woods::Error so callers can rescue the gem's hierarchy.
|
|
9
9
|
class ExportError < Woods::Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a computed write target resolves outside the vault root.
|
|
12
|
+
# The fail-closed counterpart to the dir_for/NameMapper sanitizers: those
|
|
13
|
+
# are expected to prevent this from ever firing, but a future regression
|
|
14
|
+
# in either one must refuse the write, not silently escape the vault.
|
|
15
|
+
class PathTraversalError < ExportError; end
|
|
10
16
|
end
|
|
11
17
|
end
|
|
@@ -32,9 +32,14 @@ module Woods
|
|
|
32
32
|
class NameMapper
|
|
33
33
|
MAX_FILENAME_BYTES = 255
|
|
34
34
|
EXTENSION = '.md'
|
|
35
|
-
|
|
35
|
+
HASH_SUFFIX_HEX = 8 # hex chars per disambiguation step
|
|
36
36
|
RESERVED_BASENAMES = %w[_index].freeze
|
|
37
37
|
|
|
38
|
+
# Basenames Windows refuses to create, with or without an extension.
|
|
39
|
+
# A Ruby class named +Aux+ produced "models/Aux.md", which breaks any
|
|
40
|
+
# vault synced through Windows Obsidian or OneDrive (EXP-9).
|
|
41
|
+
WINDOWS_RESERVED_BASENAMES = /\A(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i
|
|
42
|
+
|
|
38
43
|
# @param id_to_dir [Hash{String=>String}] map of unit identifier -> type
|
|
39
44
|
# folder name (e.g. "User" => "models")
|
|
40
45
|
def initialize(id_to_dir)
|
|
@@ -43,29 +48,12 @@ module Woods
|
|
|
43
48
|
build(id_to_dir)
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
# @return [Array<String>] exported identifiers, sorted
|
|
47
|
-
def ids
|
|
48
|
-
@map.keys
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @param id [String]
|
|
52
|
-
# @return [Boolean] whether this id has an emitted note
|
|
53
|
-
def known?(id)
|
|
54
|
-
@map.key?(id)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
51
|
# @param id [String]
|
|
58
52
|
# @return [String, nil] vault-relative note path ("models/User.md")
|
|
59
53
|
def path_for(id)
|
|
60
54
|
@map[id]&.fetch(:path)
|
|
61
55
|
end
|
|
62
56
|
|
|
63
|
-
# @param id [String]
|
|
64
|
-
# @return [String, nil] wikilink target with no extension ("models/User")
|
|
65
|
-
def target_for(id)
|
|
66
|
-
@map[id]&.fetch(:target)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
57
|
# @param id [String]
|
|
70
58
|
# @return [String, nil] full wikilink ("[[models/User|User]]") or nil if unknown
|
|
71
59
|
def wikilink(id)
|
|
@@ -93,18 +81,46 @@ module Woods
|
|
|
93
81
|
end
|
|
94
82
|
end
|
|
95
83
|
|
|
84
|
+
# Claim a free basename in this folder, case-insensitively.
|
|
85
|
+
#
|
|
86
|
+
# The hashed candidate used to be inserted without being re-checked, so
|
|
87
|
+
# a hash prefix that happened to equal an existing literal basename put
|
|
88
|
+
# two notes at one path — last writer wins, and one id silently vanished
|
|
89
|
+
# from the inverse map (EXP-9). Now the digest slice widens until the
|
|
90
|
+
# candidate is free.
|
|
96
91
|
def assign_basename(id, used)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
candidate = fit(sanitize(id), EXTENSION.bytesize)
|
|
93
|
+
digest = Digest::SHA256.hexdigest(id)
|
|
94
|
+
attempt = 0
|
|
95
|
+
|
|
96
|
+
while used.include?(candidate.downcase)
|
|
97
|
+
attempt += 1
|
|
98
|
+
candidate = hashed_basename(id, hash_suffix(digest, attempt))
|
|
100
99
|
end
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
|
|
101
|
+
used << candidate.downcase
|
|
102
|
+
candidate
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Widening slices of the digest (8, 16, ... 64 hex chars), then the whole
|
|
106
|
+
# digest plus a counter — so {#assign_basename} terminates even in the
|
|
107
|
+
# (unreachable, ids are unique) case that every slice is taken.
|
|
108
|
+
def hash_suffix(digest, attempt)
|
|
109
|
+
width = attempt * HASH_SUFFIX_HEX
|
|
110
|
+
return digest[0, width] if width <= digest.length
|
|
111
|
+
|
|
112
|
+
"#{digest}-#{attempt - (digest.length / HASH_SUFFIX_HEX)}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def hashed_basename(id, suffix)
|
|
116
|
+
"#{fit(sanitize(id), EXTENSION.bytesize + 1 + suffix.bytesize)}-#{suffix}"
|
|
103
117
|
end
|
|
104
118
|
|
|
105
119
|
def sanitize(id)
|
|
106
120
|
out = id.to_s.gsub('::', '__').gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
107
|
-
|
|
121
|
+
return 'unit' if out.empty?
|
|
122
|
+
|
|
123
|
+
WINDOWS_RESERVED_BASENAMES.match?(out) ? "_#{out}" : out
|
|
108
124
|
end
|
|
109
125
|
|
|
110
126
|
# Replace only the chars that are structural inside a wikilink alias.
|