@@ -140,6 +140,10 @@ def _resolve_configured_dir(
140140 category = category ,
141141 project_root = PROJECT_ROOT ,
142142 )
143+ obsidian_dir = _obsidian_month_dir (base_dir , paper or {}, category )
144+ if obsidian_dir is not None :
145+ obsidian_dir .mkdir (parents = True , exist_ok = True )
146+ return obsidian_dir .resolve ()
143147 if _env_flag ("PAPERFLOW_STORAGE_MONTHLY_SUBDIR" , default = True ):
144148 base_dir = base_dir / _month_folder_name (paper or {})
145149 base_dir .mkdir (parents = True , exist_ok = True )
@@ -169,6 +173,50 @@ def _month_folder_name(paper: Dict[str, Any]) -> str:
169173 return f"arXiv - { MONTH_LABELS .get (date .month , date .strftime ('%b' ))} { date .year } "
170174
171175
176+ def _daily_note_folder_name (paper : Dict [str , Any ]) -> str :
177+ date = _parse_publish_month (paper )
178+ return f"Daily Note { date .year } "
179+
180+
181+ def _daily_note_file_name (paper : Dict [str , Any ]) -> str :
182+ date = _parse_publish_month (paper )
183+ return f"Daily Note - { MONTH_LABELS .get (date .month , date .strftime ('%b' ))} { date .year } .md"
184+
185+
186+ def _deep_reading_folder_name (paper : Dict [str , Any ]) -> str :
187+ date = _parse_publish_month (paper )
188+ return f"Deep Reading - { MONTH_LABELS .get (date .month , date .strftime ('%b' ))} { date .year } "
189+
190+
191+ def _looks_like_obsidian_daily_root (path : Path ) -> bool :
192+ name = path .name
193+ if name == "Daily Note" :
194+ return True
195+ if re .fullmatch (r"Daily Note 20\d{2}" , name ):
196+ return True
197+ return any (path .glob ("Daily Note 20[0-9][0-9]" )) or any (path .glob ("Daily Note - * 20[0-9][0-9].md" ))
198+
199+
200+ def _obsidian_year_dir (base_dir : Path , paper : Dict [str , Any ]) -> Optional [Path ]:
201+ if not _looks_like_obsidian_daily_root (base_dir ):
202+ return None
203+ year_dir_name = _daily_note_folder_name (paper )
204+ if re .fullmatch (r"Daily Note 20\d{2}" , base_dir .name ):
205+ return base_dir
206+ return base_dir / year_dir_name
207+
208+
209+ def _obsidian_month_dir (base_dir : Path , paper : Dict [str , Any ], kind : str ) -> Optional [Path ]:
210+ year_dir = _obsidian_year_dir (base_dir , paper )
211+ if year_dir is None :
212+ return None
213+ if kind == "pdf" :
214+ return year_dir / _month_folder_name (paper )
215+ if kind == "reading_reports" :
216+ return year_dir / _deep_reading_folder_name (paper )
217+ return None
218+
219+
172220def _safe_filename (value : Any , * , max_len : int = 120 ) -> str :
173221 text = _clean_text (value )
174222 text = re .sub (r"[\\/:*?\"<>|]+" , "-" , text )
@@ -191,6 +239,13 @@ def _paper_file_stem(paper: Dict[str, Any]) -> str:
191239 return _safe_filename (paper_id or "paper" )
192240
193241
242+ def _paper_title_file_stem (paper : Dict [str , Any ], * , max_len : int = 96 ) -> str :
243+ title = _clean_text (paper .get ("title" ))
244+ if title :
245+ return _safe_filename (title , max_len = max_len )
246+ return _paper_file_stem (paper )
247+
248+
194249def _wiki_ingest_enabled () -> bool :
195250 return os .environ .get ("PAPERFLOW_WIKI_INGEST" , "1" ).strip ().lower () not in {
196251 "0" ,
@@ -1757,6 +1812,124 @@ def _persist_pdf_file(
17571812 return str (target_path )
17581813
17591814
1815+ def _obsidian_daily_note_path (report_path : Path , paper : Dict [str , Any ]) -> Optional [Path ]:
1816+ deep_dir = report_path .parent
1817+ year_dir = deep_dir .parent
1818+ if not deep_dir .name .startswith ("Deep Reading - " ) or not re .fullmatch (r"Daily Note 20\d{2}" , year_dir .name ):
1819+ return None
1820+ return year_dir / _daily_note_file_name (paper )
1821+
1822+
1823+ def _obsidian_note_link (report_path : Path ) -> str :
1824+ year_dir = report_path .parent .parent
1825+ try :
1826+ relative = report_path .relative_to (year_dir ).with_suffix ("" )
1827+ except ValueError :
1828+ relative = report_path .with_suffix ("" )
1829+ return str (relative ).replace (os .sep , "/" )
1830+
1831+
1832+ def _daily_note_category (paper : Dict [str , Any ], report_payload : Dict [str , Any ]) -> str :
1833+ values : List [str ] = []
1834+ for key in ("title" , "abstract" , "summary" , "venue" , "source" ):
1835+ values .append (_clean_text (paper .get (key )))
1836+ for value in paper .get ("subjects" ) or paper .get ("categories" ) or []:
1837+ values .append (_clean_text (value ))
1838+ for value in report_payload .get ("keywords" ) or []:
1839+ values .append (_clean_text (value ))
1840+ text = " " .join (value for value in values if value ).lower ()
1841+ if any (term in text for term in ("education" , "classroom" , "student" , "teacher" , "learning path" , "k-12" , "school" )):
1842+ return "AI for Education"
1843+ if any (term in text for term in ("protein" , "molecular" , "molecule" , "biology" , "bio" , "chemistry" , "materials science" , "scientific discovery" , "ai for science" )):
1844+ return "AI for Science"
1845+ if any (term in text for term in ("agent" , "multi-agent" , "tool" , "orchestration" )):
1846+ return "AI Agents"
1847+ if any (term in text for term in ("vision" , "image" , "video" , "3d" , "segmentation" )):
1848+ return "Computer Vision"
1849+ if any (term in text for term in ("language" , "llm" , "nlp" , "retrieval" , "rag" , "reasoning" )):
1850+ return "Language Models"
1851+ if any (term in text for term in ("reinforcement" , "diffusion" , "learning" , "optimization" )):
1852+ return "Machine Learning"
1853+ return "AI Research"
1854+
1855+
1856+ def _daily_note_entry (
1857+ * ,
1858+ paper : Dict [str , Any ],
1859+ report_payload : Dict [str , Any ],
1860+ report_path : Path ,
1861+ ) -> str :
1862+ title = _clean_text (paper .get ("title" )) or report_path .stem
1863+ marker_key = _clean_text (paper .get ("arxiv_id" ) or paper .get ("doi" ) or title )
1864+ marker = f"<!-- paperflow:{ hashlib .sha1 (marker_key .encode ('utf-8' )).hexdigest ()[:16 ]} -->"
1865+ note_link = _obsidian_note_link (report_path )
1866+ pdf_url = _clean_text (paper .get ("pdf_url" )) or _get_direct_pdf_url (paper )
1867+ summary = _clean_text (
1868+ report_payload .get ("one_sentence_summary" )
1869+ or report_payload .get ("clean_abstract_summary" )
1870+ or paper .get ("abstract" )
1871+ )
1872+ if len (summary ) > 900 :
1873+ summary = summary [:900 ].rstrip () + "..."
1874+ star = "⭐" if str (report_payload .get ("recommendation_label" ) or "" ).lower () in {"highly_recommended" , "must_read" , "high_match" } else ""
1875+ lines = [
1876+ marker ,
1877+ f"## { star } { title } " .rstrip (),
1878+ "" ,
1879+ f"[[{ note_link } |Deep Reading]]" ,
1880+ ]
1881+ if pdf_url :
1882+ lines .extend (["" , f"[{ pdf_url } ]({ pdf_url } )" ])
1883+ if summary :
1884+ lines .extend (["" , f"- **{ summary } **" ])
1885+ return "\n " .join (lines ).rstrip () + "\n "
1886+
1887+
1888+ def _sync_obsidian_daily_note (
1889+ * ,
1890+ paper : Dict [str , Any ],
1891+ report_payload : Dict [str , Any ],
1892+ report_path : Path ,
1893+ ) -> Optional [str ]:
1894+ daily_note = _obsidian_daily_note_path (report_path , paper )
1895+ if daily_note is None :
1896+ return None
1897+ daily_note .parent .mkdir (parents = True , exist_ok = True )
1898+ category = _daily_note_category (paper , report_payload )
1899+ entry = _daily_note_entry (paper = paper , report_payload = report_payload , report_path = report_path )
1900+ marker = entry .splitlines ()[0 ]
1901+ current = daily_note .read_text (encoding = "utf-8" ) if daily_note .exists () else ""
1902+ if marker in current :
1903+ return str (daily_note )
1904+ section_heading = f"# { category } "
1905+ if section_heading not in current :
1906+ current = current .rstrip () + ("\n \n " if current .strip () else "" ) + section_heading + "\n "
1907+ insert_at = current .find (section_heading ) + len (section_heading )
1908+ next_section = current .find ("\n # " , insert_at )
1909+ if next_section == - 1 :
1910+ updated = current .rstrip () + "\n \n " + entry
1911+ else :
1912+ updated = current [:next_section ].rstrip () + "\n \n " + entry + "\n " + current [next_section :].lstrip ("\n " )
1913+ daily_note .write_text (updated .rstrip () + "\n " , encoding = "utf-8" )
1914+ _sync_obsidian_toc (daily_note )
1915+ return str (daily_note )
1916+
1917+
1918+ def _sync_obsidian_toc (daily_note : Path ) -> None :
1919+ year_dir = daily_note .parent
1920+ match = re .fullmatch (r"Daily Note - ([A-Za-z]+) (20\d{2})\.md" , daily_note .name )
1921+ if not match :
1922+ return
1923+ month_label , year = match .groups ()
1924+ toc = year_dir / f"Table of Content - { year } .md"
1925+ link_name = daily_note .stem
1926+ entry = f"# { link_name } \n [[{ link_name } ]]"
1927+ current = toc .read_text (encoding = "utf-8" ) if toc .exists () else ""
1928+ if f"[[{ link_name } ]]" in current :
1929+ return
1930+ toc .write_text (current .rstrip () + ("\n " if current .strip () else "" ) + entry + "\n " , encoding = "utf-8" )
1931+
1932+
17601933def _save_reading_report_markdown (
17611934 * ,
17621935 user_id : str ,
@@ -1773,7 +1946,13 @@ def _save_reading_report_markdown(
17731946 user_id = user_id ,
17741947 category = "reading_reports" ,
17751948 )
1776- report_path = target_dir / f"{ _paper_file_stem (paper )} - reading-report.md"
1949+ obsidian_report = target_dir .name .startswith ("Deep Reading - " )
1950+ report_path = target_dir / (
1951+ f"{ _paper_title_file_stem (paper )} .md"
1952+ if obsidian_report
1953+ else f"{ _paper_file_stem (paper )} - reading-report.md"
1954+ )
1955+ daily_note_path = _obsidian_daily_note_path (report_path , paper ) if obsidian_report else None
17771956 metadata = {
17781957 "user_id" : user_id ,
17791958 "paper_id" : paper .get ("id" ),
@@ -1788,14 +1967,31 @@ def _save_reading_report_markdown(
17881967 "generation_provider" : report_payload .get ("generation_provider" ),
17891968 "generation_model" : report_payload .get ("generation_model" ),
17901969 "report_version" : READING_REPORT_OUTPUT_VERSION ,
1970+ "daily_note_path" : str (daily_note_path ) if daily_note_path else None ,
17911971 "saved_at" : datetime .now ().isoformat (timespec = "seconds" ),
17921972 }
17931973 frontmatter = ["---" ]
17941974 for key , value in metadata .items ():
17951975 if value not in (None , "" , [], {}):
17961976 frontmatter .append (f"{ key } : { json .dumps (value , ensure_ascii = False )} " )
17971977 frontmatter .extend (["---" , "" ])
1798- report_path .write_text ("\n " .join (frontmatter ) + report_content .strip () + "\n " , encoding = "utf-8" )
1978+ obsidian_header = ""
1979+ if obsidian_report and daily_note_path :
1980+ daily_note_link = daily_note_path .with_suffix ("" ).name
1981+ pdf_url = _clean_text (paper .get ("pdf_url" )) or _get_direct_pdf_url (paper )
1982+ pdf_line = f"- PDF:[{ pdf_url } ]({ pdf_url } )\n " if pdf_url else ""
1983+ obsidian_header = f"[[{ daily_note_link } ]]\n \n { pdf_line } \n "
1984+ report_path .write_text (
1985+ "\n " .join (frontmatter ) + obsidian_header + report_content .strip () + "\n " ,
1986+ encoding = "utf-8" ,
1987+ )
1988+ daily_note_written = _sync_obsidian_daily_note (
1989+ paper = paper ,
1990+ report_payload = report_payload ,
1991+ report_path = report_path ,
1992+ )
1993+ if daily_note_written :
1994+ print (f" Updated Daily Note: { daily_note_written } " )
17991995 print (f" Saved reading markdown: { report_path } " )
18001996 return str (report_path )
18011997
@@ -1891,6 +2087,41 @@ def enrich_paper_for_reading_report(
18912087 )
18922088 if pdf_url and not should_parse_pdf :
18932089 print (f" Skipping PDF enrichment ({ skip_reason } )" )
2090+ if _env_flag ("PAPERFLOW_SAVE_PDF_WITHOUT_ENRICHMENT" , default = True ):
2091+ download_candidates = _build_pdf_download_candidates (enriched , pdf_url ) or [pdf_url ]
2092+ stored_pdf_path : Optional [str ] = None
2093+ try :
2094+ for download_candidate in download_candidates :
2095+ try :
2096+ download_referer = _pick_download_referer (enriched , download_candidate )
2097+ print (f" Downloading PDF for storage: { download_candidate [:120 ]} " )
2098+ temp_pdf_path = _download_pdf (
2099+ download_candidate ,
2100+ enriched .get ("title" , "paper" ),
2101+ referer = download_referer ,
2102+ )
2103+ enriched ["pdf_url" ] = download_candidate
2104+ stored_pdf_path = _persist_pdf_file (
2105+ temp_pdf_path ,
2106+ enriched ,
2107+ download_candidate ,
2108+ user_id = user_id ,
2109+ )
2110+ if stored_pdf_path :
2111+ enriched ["pdf_path" ] = stored_pdf_path
2112+ break
2113+ except Exception as exc :
2114+ pdf_error = str (exc )
2115+ print (f" PDF storage download failed ({ download_candidate [:120 ]} ): { exc } " )
2116+ continue
2117+ finally :
2118+ if temp_pdf_path :
2119+ try :
2120+ temp_path = Path (temp_pdf_path )
2121+ if not stored_pdf_path or temp_path .resolve () != Path (stored_pdf_path ).resolve ():
2122+ temp_path .unlink (missing_ok = True )
2123+ except OSError :
2124+ pass
18942125
18952126 if pdf_url and should_parse_pdf :
18962127 download_candidates = _build_pdf_download_candidates (enriched , pdf_url ) or [pdf_url ]
0 commit comments