first commit
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: obsidian-literature-workflow
|
||||
description: Use this skill for project-scoped literature review built on Sources/Papers, with synthesis landing in Knowledge, writing handoff in Writing, and the default literature canvas under Maps/literature.canvas.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Obsidian Literature Workflow
|
||||
|
||||
This skill owns the **project literature workflow**.
|
||||
|
||||
## Main flow
|
||||
|
||||
```text
|
||||
Sources/Papers/ -> Knowledge/ -> Writing/ -> Maps/literature.canvas
|
||||
```
|
||||
|
||||
## Default outputs
|
||||
|
||||
- `Sources/Papers/{paper-slug}.md`
|
||||
- `Knowledge/Literature Overview.md`
|
||||
- `Knowledge/Method Taxonomy.md`
|
||||
- `Knowledge/Research Gaps.md`
|
||||
- `Knowledge/Claim Map.md`
|
||||
- `Writing/related-work-draft.md` only after promoted claims pass the evidence gate
|
||||
- `Writing/comparison-matrix.md` only after promoted claims pass the evidence gate
|
||||
- `Maps/literature.canvas`
|
||||
|
||||
## Rules
|
||||
|
||||
- paper notes stay under `Sources/Papers/`
|
||||
- literature synthesis does not live inside source notes
|
||||
- every synthesis note must link its supporting source notes
|
||||
- every research gap must carry evidence
|
||||
- every promoted claim must carry an Evidence Record ID, source type, claim strength, allowed wording, and forbidden stronger wording when it may flow into writing or rebuttal
|
||||
- weak paper notes, abstract-only pages, and webpage placeholders may update coverage or `To-Read`, but they cannot support `Knowledge` or `Writing` conclusions
|
||||
- default graph output is `Maps/literature.canvas`
|
||||
- do not generate extra canvases unless explicitly requested
|
||||
|
||||
## Read next
|
||||
|
||||
- `references/PAPER-NOTE-SCHEMA.md`
|
||||
- `references/LITERATURE-OVERVIEW.md`
|
||||
- `references/CLAIM-EXTRACTION.md`
|
||||
- `references/METHOD-TAXONOMY.md`
|
||||
- `references/RESEARCH-GAPS.md`
|
||||
- `references/LITERATURE-CANVAS.md`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Literature Canvas Workflow
|
||||
|
||||
## Default output
|
||||
|
||||
- `Maps/literature.canvas`
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide a default Obsidian literature graph that:
|
||||
- keeps `Sources/Papers/` as the canonical paper-note surface,
|
||||
- keeps `Knowledge/` as the canonical synthesis surface,
|
||||
- visualizes paper-to-paper and paper-to-knowledge relationships,
|
||||
- stays lightweight enough to refresh after each major Zotero ingestion.
|
||||
|
||||
## Default behavior
|
||||
|
||||
- Use paper-note frontmatter and wikilinks as the primary graph source.
|
||||
- Use `Sources/Papers/*.md` and relevant `Knowledge/*.md` as file nodes.
|
||||
- Create `.canvas` by default for literature ingestion and review workflows.
|
||||
- Treat Mermaid or markdown graph notes as optional legacy companions, not the default graph artifact.
|
||||
- Prefer argument-map structure with `paper`, `claim`, `method`, and `gap` nodes over raw all-to-all paper linking.
|
||||
- Thin edges aggressively; keep only the main reasoning chain and a small number of explicit semantic paper-to-paper relations.
|
||||
- Hide or down-rank side branches when they clutter the display graph.
|
||||
|
||||
## Refresh triggers
|
||||
|
||||
Refresh the literature canvas when:
|
||||
- new Zotero-sourced paper notes are added,
|
||||
- paper notes gain new `linked_knowledge` edges or meaningful wikilinks,
|
||||
- knowledge synthesis notes are updated after a literature pass,
|
||||
- a batch Zotero review or note-ingestion pass finishes,
|
||||
- a full-collection normalization pass changes many paper-note relationships.
|
||||
|
||||
## Recommended command
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_canvas.py" --cwd "$PWD"
|
||||
```
|
||||
|
||||
## Display rule
|
||||
|
||||
- If a second lightweight showcase graph is useful, maintain `Maps/literature-main.canvas` as a filtered presentation copy rather than bloating the default working canvas.
|
||||
@@ -0,0 +1,45 @@
|
||||
# CLAIM EXTRACTION
|
||||
|
||||
Claim extraction should produce reusable literature notes, not vague summaries.
|
||||
|
||||
## Extract at least these fields
|
||||
|
||||
For each paper or source note, identify:
|
||||
- **Claim** — what the paper says it achieves or establishes
|
||||
- **Claim type** — author claim, community consensus, or project interpretation
|
||||
- **Claim strength** — speculative, observed, supported, or strong
|
||||
- **Evidence** — the concrete support for that claim (dataset, metric, experiment, analysis)
|
||||
- **Method** — the approach or mechanism behind the claim
|
||||
- **Limitation** — where the claim may not hold
|
||||
- **Contradicts / weakens** — evidence or conditions that reduce confidence in the claim
|
||||
- **Project relevance** — why this matters for the current project
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Write claims in plain, reusable language.
|
||||
- Distinguish the author claim from your project interpretation.
|
||||
- Do not copy entire abstract sentences when a shorter paraphrase is clearer.
|
||||
- Pair every durable claim with at least one evidence anchor.
|
||||
- Do not promote a claim strength without naming the evidence that justifies the stronger level.
|
||||
|
||||
## Minimal output shape
|
||||
|
||||
This `Key Claims` block is a paper-note projection of the shared Evidence Record contract. Preserve the same evidence anchor, limitation, contradiction, project relevance, and claim strength so downstream synthesis can map it back to the canonical evidence record.
|
||||
|
||||
```md
|
||||
## Key Claims
|
||||
- Claim: ...
|
||||
- Claim type: author claim | community consensus | project interpretation
|
||||
- Claim strength: speculative | observed | supported | strong
|
||||
- Evidence: ...
|
||||
- Method: ...
|
||||
- Limitation: ...
|
||||
- Contradicts / weakens: ...
|
||||
- Project relevance: ...
|
||||
```
|
||||
|
||||
## Promotion rule
|
||||
|
||||
If a claim is reusable across multiple papers or sources, promote it from a paper note into `Knowledge/` instead of leaving it stranded in `Sources/Papers/`.
|
||||
|
||||
Only promote claims that include both an evidence anchor and a claim strength. If the evidence is weak or indirect, keep the claim as `speculative` or `observed` and preserve the uncertainty.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Legacy Literature Map Workflow
|
||||
|
||||
This reference is kept for backwards compatibility only.
|
||||
|
||||
## Legacy output
|
||||
|
||||
- `Knowledge/Literature-Map.md`
|
||||
|
||||
## Current default
|
||||
|
||||
- Use `CANVAS-WORKFLOW.md` for the current default literature graph artifact.
|
||||
- The default graph is now `Maps/literature.canvas`, not a Mermaid note.
|
||||
- Keep this markdown workflow only when a note-based map is specifically requested or needed for compatibility.
|
||||
|
||||
## Refresh triggers
|
||||
|
||||
Refresh the markdown map only when:
|
||||
- new Zotero-sourced paper notes are added,
|
||||
- paper-note links or metadata materially change,
|
||||
- a batch literature review finishes.
|
||||
|
||||
## Recommended command
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_graph.py" --cwd "$PWD"
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
# LITERATURE CANVAS
|
||||
|
||||
The default literature canvas lives at Maps/literature.canvas and should stay sparse, argument-oriented, and source-backed.
|
||||
@@ -0,0 +1,3 @@
|
||||
# LITERATURE OVERVIEW
|
||||
|
||||
Literature overview notes summarize the current field view for the project and must link source paper notes.
|
||||
@@ -0,0 +1,3 @@
|
||||
# METHOD TAXONOMY
|
||||
|
||||
Method taxonomy notes group methods across papers and should link each taxonomy entry back to supporting paper notes.
|
||||
@@ -0,0 +1,3 @@
|
||||
# PAPER NOTE SCHEMA
|
||||
|
||||
Use a strong paper schema with metadata, project relevance, summary, claims, methods, evidence, limitations, connections, and open questions.
|
||||
@@ -0,0 +1,3 @@
|
||||
# RESEARCH GAPS
|
||||
|
||||
Research gap notes must state the gap, why it matters, and which paper notes support it.
|
||||
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
|
||||
LIST_KEYS = {
|
||||
"authors",
|
||||
"keywords",
|
||||
"concepts",
|
||||
"methods",
|
||||
"related_papers",
|
||||
"linked_knowledge",
|
||||
"paper_relationships",
|
||||
"linked_papers",
|
||||
"argument_claims",
|
||||
"argument_methods",
|
||||
"argument_gaps",
|
||||
"linked_claims",
|
||||
"linked_methods",
|
||||
"linked_gaps",
|
||||
"claim_method_links",
|
||||
"method_gap_links",
|
||||
}
|
||||
PAPER_LIMIT = 48
|
||||
KNOWLEDGE_LIMIT = 18
|
||||
KNOWLEDGE_COLUMNS = 3
|
||||
NODE_WIDTH = 300
|
||||
NODE_HEIGHT = 180
|
||||
ARG_NODE_WIDTH = 280
|
||||
ARG_NODE_HEIGHT = 120
|
||||
X_GAP = 80
|
||||
Y_GAP = 70
|
||||
GROUP_X_GAP = 180
|
||||
GROUP_Y_GAP = 180
|
||||
ARG_GROUP_X_GAP = 200
|
||||
PAPER_RELATION_LIMIT = 2
|
||||
PAPER_CLAIM_LIMIT = 1
|
||||
PAPER_METHOD_LIMIT = 1
|
||||
PAPER_RELATION_LIMIT = 1
|
||||
SUBFIELD_ORDER = (
|
||||
"speech-transfer-constraints",
|
||||
"alignment-and-domain-adaptation",
|
||||
"geometry-and-representation",
|
||||
"subject-aware-adaptation",
|
||||
"other",
|
||||
)
|
||||
SUBFIELD_LABELS = {
|
||||
"speech-transfer-constraints": "Speech-specific transfer constraints",
|
||||
"alignment-and-domain-adaptation": "Alignment and domain adaptation",
|
||||
"geometry-and-representation": "Geometry and representation",
|
||||
"subject-aware-adaptation": "Subject-aware adaptation",
|
||||
"other": "Other papers",
|
||||
}
|
||||
SUBFIELD_COLORS = {
|
||||
"speech-transfer-constraints": "6",
|
||||
"alignment-and-domain-adaptation": "2",
|
||||
"geometry-and-representation": "5",
|
||||
"subject-aware-adaptation": "3",
|
||||
"other": "1",
|
||||
}
|
||||
SUBFIELD_COLUMNS = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoteRecord:
|
||||
title: str
|
||||
note_relpath: str
|
||||
vault_relpath: str
|
||||
file_name: str
|
||||
related_papers: tuple[str, ...]
|
||||
linked_knowledge: tuple[str, ...]
|
||||
paper_relationships: tuple[tuple[str, str], ...]
|
||||
linked_papers: tuple[str, ...]
|
||||
claim_refs: tuple[str, ...]
|
||||
method_refs: tuple[str, ...]
|
||||
gap_refs: tuple[str, ...]
|
||||
claim_method_links: tuple[tuple[str, str, str], ...]
|
||||
method_gap_links: tuple[tuple[str, str, str], ...]
|
||||
wikilinks: tuple[str, ...]
|
||||
subfield: str
|
||||
canvas_visibility: str
|
||||
|
||||
|
||||
def load_project_kb_module() -> Any:
|
||||
script_dir = Path(__file__).resolve().parents[2] / "obsidian-project-kb-core" / "scripts"
|
||||
sys.path.insert(0, str(script_dir))
|
||||
import kb_common # type: ignore
|
||||
|
||||
return kb_common
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build a default literature canvas for the bound Obsidian project."
|
||||
)
|
||||
parser.add_argument("--cwd", default=".")
|
||||
parser.add_argument("--project-id", default="")
|
||||
parser.add_argument("--output", default="Maps/literature.canvas")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict[str, Any]:
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return {}
|
||||
lines = text[4:end].splitlines()
|
||||
data: dict[str, Any] = {}
|
||||
current_key: str | None = None
|
||||
for raw_line in lines:
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
if raw_line.startswith(" - ") or raw_line.startswith("- "):
|
||||
if current_key and current_key in LIST_KEYS:
|
||||
data.setdefault(current_key, []).append(
|
||||
raw_line.split("- ", 1)[1].strip().strip('"')
|
||||
)
|
||||
continue
|
||||
if ":" not in raw_line:
|
||||
current_key = None
|
||||
continue
|
||||
key, value = raw_line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current_key = key
|
||||
if not value:
|
||||
if key in LIST_KEYS:
|
||||
data[key] = []
|
||||
else:
|
||||
data[key] = ""
|
||||
continue
|
||||
data[key] = value.strip('"')
|
||||
return data
|
||||
|
||||
|
||||
def body_without_frontmatter(text: str) -> str:
|
||||
if not text.startswith("---\n"):
|
||||
return text
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return text
|
||||
return text[end + 5 :]
|
||||
|
||||
|
||||
def normalize_note_target(value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if cleaned.endswith(".md"):
|
||||
cleaned = cleaned[:-3]
|
||||
if cleaned.startswith("./"):
|
||||
cleaned = cleaned[2:]
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_relationship_entries(values: list[str]) -> tuple[tuple[str, str], ...]:
|
||||
parsed: list[tuple[str, str]] = []
|
||||
for raw in values:
|
||||
cleaned = raw.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
if "::" in cleaned:
|
||||
target, label = cleaned.split("::", 1)
|
||||
else:
|
||||
target, label = cleaned, "related"
|
||||
parsed.append((normalize_note_target(target), label.strip() or "related"))
|
||||
return tuple(parsed)
|
||||
|
||||
|
||||
def parse_triple_entries(values: list[str]) -> tuple[tuple[str, str, str], ...]:
|
||||
parsed: list[tuple[str, str, str]] = []
|
||||
for raw in values:
|
||||
cleaned = raw.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
parts = [part.strip() for part in cleaned.split("::")]
|
||||
if len(parts) == 2:
|
||||
parsed.append((parts[0], parts[1], "relates"))
|
||||
elif len(parts) >= 3:
|
||||
parsed.append((parts[0], parts[1], parts[2] or "relates"))
|
||||
return tuple(parsed)
|
||||
|
||||
|
||||
def parse_plain_labels(values: list[str]) -> tuple[str, ...]:
|
||||
labels = []
|
||||
for raw in values:
|
||||
cleaned = raw.strip()
|
||||
if cleaned and cleaned not in labels:
|
||||
labels.append(cleaned)
|
||||
return tuple(labels)
|
||||
|
||||
|
||||
def extract_wikilinks(text: str) -> tuple[str, ...]:
|
||||
seen: list[str] = []
|
||||
for match in WIKILINK_RE.findall(text):
|
||||
target = normalize_note_target(match)
|
||||
if target not in seen:
|
||||
seen.append(target)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def note_id(prefix: str, value: str) -> str:
|
||||
digest = hashlib.sha1(f"{prefix}:{value}".encode("utf-8")).hexdigest()
|
||||
return digest[:16]
|
||||
|
||||
|
||||
def file_node(note: NoteRecord, x: int, y: int, color: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": note_id("node", note.note_relpath),
|
||||
"type": "file",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": NODE_WIDTH,
|
||||
"height": NODE_HEIGHT,
|
||||
"file": note.vault_relpath,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def text_node(node_key: str, text: str, x: int, y: int, width: int, height: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": note_id("text", node_key),
|
||||
"type": "text",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"text": text,
|
||||
"color": "5",
|
||||
}
|
||||
|
||||
|
||||
def group_node(node_key: str, label: str, x: int, y: int, width: int, height: int, color: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": note_id("group", node_key),
|
||||
"type": "group",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"label": label,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def edge_id(source: str, target: str, label: str) -> str:
|
||||
digest = hashlib.sha1(f"{source}:{target}:{label}".encode("utf-8")).hexdigest()
|
||||
return digest[:16]
|
||||
|
||||
|
||||
def make_edge(from_node: str, to_node: str, label: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": edge_id(from_node, to_node, label),
|
||||
"fromNode": from_node,
|
||||
"fromSide": "bottom",
|
||||
"toNode": to_node,
|
||||
"toSide": "top",
|
||||
"toEnd": "arrow",
|
||||
"label": label,
|
||||
}
|
||||
|
||||
|
||||
def collect_notes(folder: Path, folder_name: str, vault_root: Path, limit: int) -> list[NoteRecord]:
|
||||
notes: list[NoteRecord] = []
|
||||
if not folder.exists():
|
||||
return notes
|
||||
for path in sorted(folder.glob("*.md"))[:limit]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
frontmatter = parse_frontmatter(text)
|
||||
body = body_without_frontmatter(text)
|
||||
title = str(frontmatter.get("title") or path.stem.replace("-", " "))
|
||||
notes.append(
|
||||
NoteRecord(
|
||||
title=title,
|
||||
note_relpath=f"{folder_name}/{path.name}",
|
||||
vault_relpath=str(path.relative_to(vault_root)).replace("\\", "/"),
|
||||
file_name=path.name,
|
||||
related_papers=tuple(
|
||||
normalize_note_target(v) for v in frontmatter.get("related_papers", [])
|
||||
),
|
||||
linked_knowledge=tuple(
|
||||
normalize_note_target(v) for v in frontmatter.get("linked_knowledge", [])
|
||||
),
|
||||
paper_relationships=parse_relationship_entries(
|
||||
list(frontmatter.get("paper_relationships", []))
|
||||
),
|
||||
linked_papers=tuple(
|
||||
normalize_note_target(v) for v in frontmatter.get("linked_papers", [])
|
||||
),
|
||||
claim_refs=parse_plain_labels(
|
||||
list(frontmatter.get("argument_claims", []))
|
||||
+ list(frontmatter.get("linked_claims", []))
|
||||
),
|
||||
method_refs=parse_plain_labels(
|
||||
list(frontmatter.get("argument_methods", []))
|
||||
+ list(frontmatter.get("linked_methods", []))
|
||||
),
|
||||
gap_refs=parse_plain_labels(
|
||||
list(frontmatter.get("argument_gaps", []))
|
||||
+ list(frontmatter.get("linked_gaps", []))
|
||||
),
|
||||
claim_method_links=parse_triple_entries(
|
||||
list(frontmatter.get("claim_method_links", []))
|
||||
),
|
||||
method_gap_links=parse_triple_entries(
|
||||
list(frontmatter.get("method_gap_links", []))
|
||||
),
|
||||
wikilinks=extract_wikilinks(body),
|
||||
subfield=str(frontmatter.get("subfield") or "other"),
|
||||
canvas_visibility=str(frontmatter.get("canvas_visibility") or "show"),
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
def looks_like_literature_knowledge(note: NoteRecord) -> bool:
|
||||
key = note.note_relpath.lower()
|
||||
if any(token in key for token in ("literature", "paper", "method", "survey", "gap", "related")):
|
||||
return True
|
||||
return any(target.startswith("Sources/Papers/") for target in note.wikilinks)
|
||||
|
||||
|
||||
def select_knowledge_notes(
|
||||
all_knowledge: list[NoteRecord], papers: list[NoteRecord]
|
||||
) -> list[NoteRecord]:
|
||||
requested = set()
|
||||
for note in papers:
|
||||
requested.update(note.linked_knowledge)
|
||||
requested.update(target for target in note.wikilinks if target.startswith("Knowledge/"))
|
||||
|
||||
selected: list[NoteRecord] = []
|
||||
seen: set[str] = set()
|
||||
for note in all_knowledge:
|
||||
note_key = note.note_relpath[:-3]
|
||||
if note_key in requested:
|
||||
selected.append(note)
|
||||
seen.add(note_key)
|
||||
|
||||
if selected:
|
||||
return selected[:KNOWLEDGE_LIMIT]
|
||||
|
||||
for note in all_knowledge:
|
||||
note_key = note.note_relpath[:-3]
|
||||
if note_key in seen:
|
||||
continue
|
||||
if looks_like_literature_knowledge(note):
|
||||
selected.append(note)
|
||||
seen.add(note_key)
|
||||
if len(selected) >= KNOWLEDGE_LIMIT:
|
||||
break
|
||||
|
||||
return selected[:KNOWLEDGE_LIMIT]
|
||||
|
||||
|
||||
def layout_grid(index: int, columns: int, x0: int, y0: int) -> tuple[int, int]:
|
||||
col = index % columns
|
||||
row = index // columns
|
||||
x = x0 + col * (NODE_WIDTH + X_GAP)
|
||||
y = y0 + row * (NODE_HEIGHT + Y_GAP)
|
||||
return x, y
|
||||
|
||||
|
||||
def compute_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
|
||||
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
|
||||
width = columns * NODE_WIDTH + (columns - 1) * X_GAP + 120
|
||||
height = rows * NODE_HEIGHT + (rows - 1) * Y_GAP + 140
|
||||
return width, height
|
||||
|
||||
|
||||
def compute_argument_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
|
||||
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
|
||||
width = columns * ARG_NODE_WIDTH + (columns - 1) * X_GAP + 120
|
||||
height = rows * ARG_NODE_HEIGHT + (rows - 1) * Y_GAP + 140
|
||||
return width, height
|
||||
|
||||
|
||||
def argument_node(kind: str, label: str, x: int, y: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": note_id(kind, label),
|
||||
"type": "text",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": ARG_NODE_WIDTH,
|
||||
"height": ARG_NODE_HEIGHT,
|
||||
"text": label,
|
||||
"color": {"claim": "4", "method": "2", "gap": "6"}.get(kind, "5"),
|
||||
}
|
||||
|
||||
|
||||
def render_canvas(project_title: str, papers: list[NoteRecord], knowledge: list[NoteRecord]) -> dict[str, Any]:
|
||||
nodes: list[dict[str, Any]] = []
|
||||
edges: list[dict[str, Any]] = []
|
||||
node_lookup: dict[str, str] = {}
|
||||
claim_lookup: dict[str, str] = {}
|
||||
method_lookup: dict[str, str] = {}
|
||||
gap_lookup: dict[str, str] = {}
|
||||
|
||||
knowledge_rows = max(1, (max(len(knowledge), 1) - 1) // KNOWLEDGE_COLUMNS + 1)
|
||||
knowledge_group_width = KNOWLEDGE_COLUMNS * NODE_WIDTH + (KNOWLEDGE_COLUMNS - 1) * X_GAP + 120
|
||||
knowledge_group_height = knowledge_rows * NODE_HEIGHT + (knowledge_rows - 1) * Y_GAP + 140
|
||||
knowledge_y = 220
|
||||
argument_y = knowledge_y + knowledge_group_height + 180
|
||||
|
||||
nodes.append(
|
||||
text_node(
|
||||
"literature-summary",
|
||||
(
|
||||
f"# Literature Canvas\\n\\n"
|
||||
f"Project: {project_title}\\n\\n"
|
||||
f"- This is an argument map with `paper + claim + method + gap`.\\n"
|
||||
f"- The graph is intentionally thinned: each paper only keeps its most important claim / method links.\\n"
|
||||
f"- Papers are grouped by subfield and spaced out to reduce crossing lines."
|
||||
),
|
||||
0,
|
||||
0,
|
||||
1080,
|
||||
150,
|
||||
)
|
||||
)
|
||||
nodes.append(group_node("knowledge-group", "Knowledge", -40, knowledge_y - 40, knowledge_group_width, knowledge_group_height, "4"))
|
||||
|
||||
for idx, note in enumerate(knowledge):
|
||||
x, y = layout_grid(idx, KNOWLEDGE_COLUMNS, 20, knowledge_y)
|
||||
node = file_node(note, x, y, "4")
|
||||
nodes.append(node)
|
||||
node_lookup[note.note_relpath[:-3]] = node["id"]
|
||||
|
||||
claim_labels: list[str] = []
|
||||
method_labels: list[str] = []
|
||||
gap_labels: list[str] = []
|
||||
for note in (*papers, *knowledge):
|
||||
for label in note.claim_refs:
|
||||
if label not in claim_labels:
|
||||
claim_labels.append(label)
|
||||
for label in note.method_refs:
|
||||
if label not in method_labels:
|
||||
method_labels.append(label)
|
||||
for label in note.gap_refs:
|
||||
if label not in gap_labels:
|
||||
gap_labels.append(label)
|
||||
|
||||
claim_group_width, claim_group_height = compute_argument_group_dimensions(len(claim_labels), 2)
|
||||
method_group_width, method_group_height = compute_argument_group_dimensions(len(method_labels), 2)
|
||||
gap_group_width, gap_group_height = compute_argument_group_dimensions(len(gap_labels), 2)
|
||||
argument_row_height = max(claim_group_height, method_group_height, gap_group_height)
|
||||
|
||||
argument_groups = [
|
||||
("claim", "Claims", claim_labels, -40, argument_y, claim_group_width, claim_group_height, "4"),
|
||||
("method", "Methods", method_labels, -40 + claim_group_width + ARG_GROUP_X_GAP, argument_y, method_group_width, method_group_height, "2"),
|
||||
("gap", "Gaps", gap_labels, -40 + claim_group_width + ARG_GROUP_X_GAP + method_group_width + ARG_GROUP_X_GAP, argument_y, gap_group_width, gap_group_height, "6"),
|
||||
]
|
||||
for kind, label, labels, group_x, group_y, group_width, group_height, color in argument_groups:
|
||||
nodes.append(group_node(f"{kind}-group", label, group_x, group_y, group_width, group_height, color))
|
||||
for idx, arg_label in enumerate(labels):
|
||||
x, y = layout_grid(idx, 2, group_x + 60, group_y + 40)
|
||||
node = argument_node(kind, arg_label, x, y)
|
||||
nodes.append(node)
|
||||
if kind == "claim":
|
||||
claim_lookup[arg_label] = node["id"]
|
||||
elif kind == "method":
|
||||
method_lookup[arg_label] = node["id"]
|
||||
else:
|
||||
gap_lookup[arg_label] = node["id"]
|
||||
|
||||
paper_y = argument_y + argument_row_height + 180
|
||||
|
||||
grouped_papers: dict[str, list[NoteRecord]] = {}
|
||||
for note in papers:
|
||||
grouped_papers.setdefault(note.subfield or "other", []).append(note)
|
||||
|
||||
active_groups = [key for key in SUBFIELD_ORDER if grouped_papers.get(key)]
|
||||
if not active_groups:
|
||||
active_groups = ["other"]
|
||||
grouped_papers["other"] = list(papers)
|
||||
|
||||
group_layout_meta: list[tuple[str, int, int, int, int]] = []
|
||||
for idx, subfield in enumerate(active_groups):
|
||||
notes_in_group = grouped_papers[subfield]
|
||||
group_width, group_height = compute_group_dimensions(len(notes_in_group), 2)
|
||||
col = idx % SUBFIELD_COLUMNS
|
||||
row = idx // SUBFIELD_COLUMNS
|
||||
group_x = -40 + col * (group_width + GROUP_X_GAP)
|
||||
group_y = paper_y + row * (group_height + GROUP_Y_GAP)
|
||||
group_layout_meta.append((subfield, group_x, group_y, group_width, group_height))
|
||||
nodes.append(
|
||||
group_node(
|
||||
f"paper-group-{subfield}",
|
||||
SUBFIELD_LABELS.get(subfield, subfield.replace("-", " ").title()),
|
||||
group_x,
|
||||
group_y,
|
||||
group_width,
|
||||
group_height,
|
||||
SUBFIELD_COLORS.get(subfield, "2"),
|
||||
)
|
||||
)
|
||||
|
||||
for note_idx, note in enumerate(notes_in_group):
|
||||
x, y = layout_grid(note_idx, 2, group_x + 60, group_y + 40)
|
||||
node = file_node(note, x, y, SUBFIELD_COLORS.get(subfield, "2"))
|
||||
nodes.append(node)
|
||||
node_lookup[note.note_relpath[:-3]] = node["id"]
|
||||
|
||||
seen_edges: set[tuple[str, str, str]] = set()
|
||||
for note in papers:
|
||||
source = node_lookup.get(note.note_relpath[:-3])
|
||||
if not source:
|
||||
continue
|
||||
explicit_relationships = list(note.paper_relationships)
|
||||
if not explicit_relationships:
|
||||
explicit_relationships = [(target, "related") for target in note.related_papers]
|
||||
for target, label in explicit_relationships[:PAPER_RELATION_LIMIT]:
|
||||
target_node = node_lookup.get(target)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, label)
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, label))
|
||||
seen_edges.add(marker)
|
||||
for claim in note.claim_refs[:PAPER_CLAIM_LIMIT]:
|
||||
target_node = claim_lookup.get(claim)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "supports")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "supports"))
|
||||
seen_edges.add(marker)
|
||||
for method in note.method_refs[:PAPER_METHOD_LIMIT]:
|
||||
target_node = method_lookup.get(method)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "uses")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "uses"))
|
||||
seen_edges.add(marker)
|
||||
for note in knowledge:
|
||||
source = node_lookup.get(note.note_relpath[:-3])
|
||||
if not source:
|
||||
continue
|
||||
for target in note.linked_papers:
|
||||
target_node = node_lookup.get(target)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "summarizes")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "summarizes"))
|
||||
seen_edges.add(marker)
|
||||
for claim in note.claim_refs:
|
||||
target_node = claim_lookup.get(claim)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "summarizes")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "summarizes"))
|
||||
seen_edges.add(marker)
|
||||
for method in note.method_refs:
|
||||
target_node = method_lookup.get(method)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "summarizes")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "summarizes"))
|
||||
seen_edges.add(marker)
|
||||
for gap in note.gap_refs:
|
||||
target_node = gap_lookup.get(gap)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "summarizes")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "summarizes"))
|
||||
seen_edges.add(marker)
|
||||
for claim, method, label in note.claim_method_links:
|
||||
source_node = claim_lookup.get(claim)
|
||||
target_node = method_lookup.get(method)
|
||||
if not source_node or not target_node:
|
||||
continue
|
||||
marker = (source_node, target_node, label)
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source_node, target_node, label))
|
||||
seen_edges.add(marker)
|
||||
for method, gap, label in note.method_gap_links:
|
||||
source_node = method_lookup.get(method)
|
||||
target_node = gap_lookup.get(gap)
|
||||
if not source_node or not target_node:
|
||||
continue
|
||||
marker = (source_node, target_node, label)
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source_node, target_node, label))
|
||||
seen_edges.add(marker)
|
||||
for target in note.wikilinks:
|
||||
if not target.startswith("Knowledge/"):
|
||||
continue
|
||||
target_node = node_lookup.get(target)
|
||||
if not target_node:
|
||||
continue
|
||||
marker = (source, target_node, "relates")
|
||||
if marker not in seen_edges:
|
||||
edges.append(make_edge(source, target_node, "relates"))
|
||||
seen_edges.add(marker)
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_kb = load_project_kb_module()
|
||||
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
|
||||
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
|
||||
|
||||
papers = [
|
||||
note
|
||||
for note in collect_notes(binding.project_root / "Sources" / "Papers", "Papers", binding.vault_path, PAPER_LIMIT)
|
||||
if note.canvas_visibility != "hidden"
|
||||
]
|
||||
all_knowledge = collect_notes(
|
||||
binding.project_root / "Knowledge", "Knowledge", binding.vault_path, KNOWLEDGE_LIMIT * 3
|
||||
)
|
||||
knowledge = select_knowledge_notes(all_knowledge, papers)
|
||||
canvas = render_canvas(project_kb.titleize_slug(binding.project_id), papers, knowledge)
|
||||
|
||||
output_path = binding.project_root / args.output
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(canvas, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"project_id": binding.project_id,
|
||||
"output": str(output_path),
|
||||
"paper_count": len(papers),
|
||||
"knowledge_count": len(knowledge),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PAPER_LIMIT = 48
|
||||
EDGE_LIMIT = 160
|
||||
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
|
||||
LIST_KEYS = {
|
||||
'authors', 'keywords', 'concepts', 'methods',
|
||||
'related_papers', 'linked_experiments', 'linked_results'
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaperNote:
|
||||
title: str
|
||||
note_relpath: str
|
||||
file_name: str
|
||||
concepts: tuple[str, ...]
|
||||
methods: tuple[str, ...]
|
||||
related_papers: tuple[str, ...]
|
||||
linked_experiments: tuple[str, ...]
|
||||
linked_results: tuple[str, ...]
|
||||
wikilinks: tuple[str, ...]
|
||||
|
||||
|
||||
def load_project_kb_module() -> Any:
|
||||
script_dir = Path(__file__).resolve().parents[2] / 'obsidian-project-kb-core' / 'scripts'
|
||||
sys.path.insert(0, str(script_dir))
|
||||
import kb_common # type: ignore
|
||||
return kb_common
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description='Build a literature graph note for the bound Obsidian project.')
|
||||
parser.add_argument('--cwd', default='.')
|
||||
parser.add_argument('--project-id', default='')
|
||||
parser.add_argument('--output', default='Knowledge/Literature-Map.md')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict[str, Any]:
|
||||
if not text.startswith('---\n'):
|
||||
return {}
|
||||
end = text.find('\n---\n', 4)
|
||||
if end == -1:
|
||||
return {}
|
||||
lines = text[4:end].splitlines()
|
||||
data: dict[str, Any] = {}
|
||||
current_key: str | None = None
|
||||
for raw_line in lines:
|
||||
if not raw_line.strip():
|
||||
continue
|
||||
if raw_line.startswith(' - ') or raw_line.startswith('- '):
|
||||
if current_key and current_key in LIST_KEYS:
|
||||
data.setdefault(current_key, []).append(raw_line.split('- ', 1)[1].strip().strip('"'))
|
||||
continue
|
||||
if ':' not in raw_line:
|
||||
current_key = None
|
||||
continue
|
||||
key, value = raw_line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current_key = key
|
||||
if not value:
|
||||
if key in LIST_KEYS:
|
||||
data[key] = []
|
||||
else:
|
||||
data[key] = ''
|
||||
continue
|
||||
data[key] = value.strip('"')
|
||||
return data
|
||||
|
||||
|
||||
def body_without_frontmatter(text: str) -> str:
|
||||
if not text.startswith('---\n'):
|
||||
return text
|
||||
end = text.find('\n---\n', 4)
|
||||
if end == -1:
|
||||
return text
|
||||
return text[end + 5:]
|
||||
|
||||
|
||||
def normalize_note_target(value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if cleaned.endswith('.md'):
|
||||
cleaned = cleaned[:-3]
|
||||
if cleaned.startswith('./'):
|
||||
cleaned = cleaned[2:]
|
||||
return cleaned
|
||||
|
||||
|
||||
def mermaid_id(prefix: str, value: str) -> str:
|
||||
slug = re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
|
||||
if not slug:
|
||||
slug = 'node'
|
||||
return f'{prefix}_{slug[:48]}'
|
||||
|
||||
|
||||
def extract_wikilinks(text: str) -> tuple[str, ...]:
|
||||
seen: list[str] = []
|
||||
for match in WIKILINK_RE.findall(text):
|
||||
target = normalize_note_target(match)
|
||||
if target not in seen:
|
||||
seen.append(target)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def collect_paper_notes(project_root: Path) -> list[PaperNote]:
|
||||
papers_dir = project_root / 'Sources' / 'Papers'
|
||||
notes: list[PaperNote] = []
|
||||
if not papers_dir.exists():
|
||||
return notes
|
||||
for path in sorted(papers_dir.glob('*.md'))[:PAPER_LIMIT]:
|
||||
text = path.read_text(encoding='utf-8')
|
||||
frontmatter = parse_frontmatter(text)
|
||||
body = body_without_frontmatter(text)
|
||||
title = str(frontmatter.get('title') or path.stem.replace('-', ' '))
|
||||
notes.append(
|
||||
PaperNote(
|
||||
title=title,
|
||||
note_relpath=f'Sources/Papers/{path.name}',
|
||||
file_name=path.name,
|
||||
concepts=tuple(frontmatter.get('concepts', [])),
|
||||
methods=tuple(frontmatter.get('methods', [])),
|
||||
related_papers=tuple(normalize_note_target(v) for v in frontmatter.get('related_papers', [])),
|
||||
linked_experiments=tuple(normalize_note_target(v) for v in frontmatter.get('linked_experiments', [])),
|
||||
linked_results=tuple(normalize_note_target(v) for v in frontmatter.get('linked_results', [])),
|
||||
wikilinks=extract_wikilinks(body),
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
def render_mermaid(notes: list[PaperNote]) -> str:
|
||||
lines: list[str] = ['graph LR']
|
||||
paper_index = {note.note_relpath[:-3]: note for note in notes}
|
||||
seen_edges: set[tuple[str, str, str]] = set()
|
||||
|
||||
for note in notes:
|
||||
paper_node = mermaid_id('paper', note.file_name)
|
||||
lines.append(f' {paper_node}["{note.title}"]')
|
||||
|
||||
for concept in note.concepts:
|
||||
concept_node = mermaid_id('concept', concept)
|
||||
lines.append(f' {concept_node}(("{concept}"))')
|
||||
edge = (paper_node, concept_node, 'concept')
|
||||
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
|
||||
lines.append(f' {paper_node} -->|concept| {concept_node}')
|
||||
seen_edges.add(edge)
|
||||
|
||||
for method in note.methods:
|
||||
method_node = mermaid_id('method', method)
|
||||
lines.append(f' {method_node}{{"{method}"}}')
|
||||
edge = (paper_node, method_node, 'method')
|
||||
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
|
||||
lines.append(f' {paper_node} -->|method| {method_node}')
|
||||
seen_edges.add(edge)
|
||||
|
||||
related_targets = set(note.related_papers)
|
||||
related_targets.update(target for target in note.wikilinks if target.startswith('Sources/Papers/'))
|
||||
for target in sorted(related_targets):
|
||||
target_note = paper_index.get(target)
|
||||
if not target_note:
|
||||
continue
|
||||
target_node = mermaid_id('paper', target_note.file_name)
|
||||
edge = (paper_node, target_node, 'related')
|
||||
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
|
||||
lines.append(f' {paper_node} -->|related| {target_node}')
|
||||
seen_edges.add(edge)
|
||||
|
||||
for experiment in note.linked_experiments:
|
||||
exp_node = mermaid_id('experiment', experiment)
|
||||
label = experiment.split('/')[-1]
|
||||
lines.append(f' {exp_node}["{label}"]')
|
||||
edge = (paper_node, exp_node, 'experiment')
|
||||
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
|
||||
lines.append(f' {paper_node} -->|experiment| {exp_node}')
|
||||
seen_edges.add(edge)
|
||||
|
||||
for result in note.linked_results:
|
||||
result_node = mermaid_id('result', result)
|
||||
label = result.split('/')[-1]
|
||||
lines.append(f' {result_node}["{label}"]')
|
||||
edge = (paper_node, result_node, 'result')
|
||||
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
|
||||
lines.append(f' {paper_node} -->|result| {result_node}')
|
||||
seen_edges.add(edge)
|
||||
|
||||
deduped: list[str] = []
|
||||
seen_lines: set[str] = set()
|
||||
for line in lines:
|
||||
if line not in seen_lines:
|
||||
deduped.append(line)
|
||||
seen_lines.add(line)
|
||||
return '\n'.join(deduped)
|
||||
|
||||
|
||||
def render_map_note(project_id: str, notes: list[PaperNote], mermaid_graph: str, updated: str) -> str:
|
||||
paper_bullets = '\n'.join(f'- [[{note.note_relpath[:-3]}]]' for note in notes) or '- No paper notes yet.'
|
||||
concept_counts: dict[str, int] = {}
|
||||
method_counts: dict[str, int] = {}
|
||||
for note in notes:
|
||||
for concept in note.concepts:
|
||||
concept_counts[concept] = concept_counts.get(concept, 0) + 1
|
||||
for method in note.methods:
|
||||
method_counts[method] = method_counts.get(method, 0) + 1
|
||||
concept_lines = '\n'.join(
|
||||
f'- {name} ({count})' for name, count in sorted(concept_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
|
||||
) or '- No concept clusters recorded yet.'
|
||||
method_lines = '\n'.join(
|
||||
f'- {name} ({count})' for name, count in sorted(method_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
|
||||
) or '- No method clusters recorded yet.'
|
||||
|
||||
return f'''---
|
||||
type: knowledge
|
||||
title: Literature Map
|
||||
project: {project_id}
|
||||
updated: {updated}
|
||||
---
|
||||
|
||||
# Literature Map
|
||||
|
||||
## Purpose
|
||||
- Provide a lightweight literature knowledge map for the current project.
|
||||
- Help navigate paper notes, concept clusters, and downstream experiment/result hooks.
|
||||
|
||||
## Paper notes included
|
||||
{paper_bullets}
|
||||
|
||||
## Concept clusters
|
||||
{concept_lines}
|
||||
|
||||
## Method clusters
|
||||
{method_lines}
|
||||
|
||||
## Graph
|
||||
```mermaid
|
||||
{mermaid_graph}
|
||||
```
|
||||
|
||||
## How to use
|
||||
- Open the linked paper notes above for detailed reading notes.
|
||||
- Use Obsidian backlinks/local graph on top of these wikilinks.
|
||||
- Refresh this note after a batch Zotero ingestion or major paper-note update.
|
||||
'''
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_kb = load_project_kb_module()
|
||||
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
|
||||
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
|
||||
notes = collect_paper_notes(binding.project_root)
|
||||
output_rel = args.output if args.output.endswith('.md') else f'{args.output}.md'
|
||||
output_path = binding.project_root / output_rel
|
||||
mermaid_graph = render_mermaid(notes)
|
||||
content = render_map_note(binding.project_id, notes, mermaid_graph, project_kb.now_iso())
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(content.rstrip() + '\n', encoding='utf-8')
|
||||
print(output_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user