From 4b7ddcb1c1388223ec4c13766953164e6f2e3be0 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 26 May 2026 14:52:09 +0200 Subject: [PATCH] Add daily coding summary tool for Obsidian Integrates with Zed editor history and Git commits to track daily coding activity. Writes idempotent `## Coding Work` sections into Obsidian daily notes with project breakdowns and commit stats. --- dailySummary/README.md | 43 ++++ dailySummary/__pycache__/main.cpython-314.pyc | Bin 0 -> 5638 bytes dailySummary/daily-summary.sh | 7 + dailySummary/main.py | 141 +++++++++++ dailySummary/utils/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 170 bytes .../utils/__pycache__/config.cpython-314.pyc | Bin 0 -> 4145 bytes .../__pycache__/git_tracker.cpython-314.pyc | Bin 0 -> 10650 bytes .../obsidian_writer.cpython-314.pyc | Bin 0 -> 6011 bytes .../__pycache__/zed_tracker.cpython-314.pyc | Bin 0 -> 7757 bytes dailySummary/utils/config.py | 61 +++++ dailySummary/utils/git_tracker.py | 225 ++++++++++++++++++ dailySummary/utils/obsidian_writer.py | 112 +++++++++ dailySummary/utils/zed_tracker.py | 140 +++++++++++ 14 files changed, 729 insertions(+) create mode 100644 dailySummary/README.md create mode 100644 dailySummary/__pycache__/main.cpython-314.pyc create mode 100755 dailySummary/daily-summary.sh create mode 100644 dailySummary/main.py create mode 100644 dailySummary/utils/__init__.py create mode 100644 dailySummary/utils/__pycache__/__init__.cpython-314.pyc create mode 100644 dailySummary/utils/__pycache__/config.cpython-314.pyc create mode 100644 dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc create mode 100644 dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc create mode 100644 dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc create mode 100644 dailySummary/utils/config.py create mode 100644 dailySummary/utils/git_tracker.py create mode 100644 dailySummary/utils/obsidian_writer.py create mode 100644 dailySummary/utils/zed_tracker.py diff --git a/dailySummary/README.md b/dailySummary/README.md new file mode 100644 index 0000000..040523e --- /dev/null +++ b/dailySummary/README.md @@ -0,0 +1,43 @@ +# Daily Coding Summary + +Track your coding activity from **Zed** editors and **Git** commits, then write a summary into your Obsidian daily note. + +## Features + +- **Zed Integration** – reads Zed's SQLite database to find which files you edited today and groups them by project. +- **Git Tracking** – scans `~/Code` (or custom directories) for git repositories and collects today's commits with stats (insertions, deletions, files changed). +- **Obsidian Daily Notes** – writes or appends a `## Coding Work` section to your daily note (`YYYY-MM-DD.md`). +- **Idempotent** – running multiple times on the same day replaces the existing `## Coding Work` section rather than duplicating it. + +## Usage + +```bash +# Use default vault (from OBSIDIAN_VAULT env var or ~/Obsidian) +python main.py + +# Specify vault and code directories +python main.py --vault ~/Documents/obsidian_vault --code-dir ~/Code --code-dir ~/Work + +# Dry-run to preview without writing +python main.py --dry-run + +# Summarize a specific day +python main.py --date 2026-05-20 + +# Skip Zed or Git tracking +python main.py --no-zed +python main.py --no-git +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `OBSIDIAN_VAULT` | Path to your Obsidian vault | auto-detected from `~/Obsidian` | +| `OBSIDIAN_DAILY_FOLDER` | Folder inside the vault for daily notes | `Daily Notes` | + +## Requirements + +- Python 3.9+ +- Git (for commit tracking) +- Zed editor (optional, for editor tracking) diff --git a/dailySummary/__pycache__/main.cpython-314.pyc b/dailySummary/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ec1bd1300dbb551d9d7bfed942cdf83e12320d9 GIT binary patch literal 5638 zcma)AZ*1Gf6+cRpL{WeISC$<+F&!tdl|)M7Bux`HTirU2mDsVyq)NTH5g3cM*~lbu zq>`A(3Srn*x7d(oEta4QP!}lH78tOvEwB$AwoltW$hBLmoP!2!fnnd=Cc}ztANKAf zN~)V6JxILc-Me@1-o5wk_qZn<;t`Zz{5tad-bREzCofKMRT}r+fkpv|$V3;A$Rrt) zp|#6&LG4PqN8P4-lr`CIGVV@#M!lw&w%Md_lruTn_9XqIyvdIS%m97&CWE6PGeq0I zWO%f}Y@lr}**Mx{HgzM>A4MV`MY%?^Sqwyx+45Yg7<~L$gt8!W7kaLx2OX-22-Hk3 z{l`MmAqJsgNy#k;Pit94ofXdJ=jSE7Ak1i3n7C*tSw&KL{v4L3FA39S5^360E-NO` zU~OLbp_~=utYT`|5F|A#oK#F8oS#=rW3OP&$*OP#E2hj#f>B|usHP??X!+U#LY9i4 zYNnjv`DYAiRzAiHQ1k_JPE&<>Nl_E}!UMX5c>J=I&zZst`y6TajW480*Mq5H4{|)K zV3+_qmSKKf&v^}BdhoQYWXi(8{(;Bh`wzzl_VZ_AE}J8rrZO+v9C=!rD#sX`O-be) z)ZBz_Dw-LY!BJwANg})pA_x8i1fYRuK>+zq089-kb`T;Y?h|Cm>l#A$^ats&kbM>N_ z`=8{!yt9m9V%v*lGmll+gBxBk83iG{U>)<4X^=&?+pSc_is9_5oRuBSWF!?%loqkuY5P5M+-b{MLZ z9@w$S9IA}yOqxk~oUvLxMYz(E;+$5k7F|aw+Drv%y-v$O2URLOhSo|kH?3H1<*TjOzJff zcuLvk1nuJU^0b+-xSf;no%8XXS#mC>rKmHS)s|E*OF1P=sfjDYVI33ttY}|CIB`{n zdEm6QB@!_|Jz1NBwZLk@qJ3D_bJDboi7nagyg6<2lHr)S&8MYYUOs`bhHXy5vpNyl zZW_X~`FUA2&n@WX!I%q&2m@EvG{~9k5^xiF=jjA6Zd1nFpo5ym3^WR8gY|m&^-yFj zwC%&twp&-<*|HKkwCE}|G+nn|vfgTcyX(!aMQf!YvDR>Ox#8$a!(fpc{CYhQxsTj1 zG2FHm?*A~{UkdM7dUoklF>#_89J%LqHSinA<>xotK=dDjoZC-^*dZVKqi@$x+&vBN z)j~{yZ2R68Fq6U~4Ne4ua|}iY^LvoNi7wGCvZ6=y9%0MtbU%#JCvu`6Xn8Skq++@c zr45Q9pbUQpt_@;i&FEoHli2)7?k!@g7!ljV=p$ur7dwD*%Xgsc6uW?O>n2KnRrl3~ z<7PSoE?;#+aObMaRY{TKNV*Hg+iU$!tFO|?f@^l62h-^6xBxdYn+)lmv?m45yt$;h zH}@bU3Sv(+*s#D^_%@C1O?#^nJWeI>Hlk;Ic~=7ET_d{@(tT6C&b+kG*-dJv(^sn< zt)v`=wV~?$e#UirFG7B_4e4IpH@AxTWI^q=A0Q0Zz2{#~d&M0`i1$O$19kK}r;e9<5lRP~xe&;A z)dD&ADU=r_p_e25G|XD-Olze$Ft9A35Bks(X<0G1w0ZXfN{ z0aMoT-|g_M?@fn#(D^@r)+<_h5RJH})+@5TXe#Nf{H!Y-NC(qwniKar5l0W#tr<^; zgJ{6@&G~S(RS}{j&#M zU}_R#xzzaLbQAp3~PHREIeZ!ClHcM0r*^|?6T=DyV4wl^ zhn0laA{^cU4=WsxQ;o%Ev|LukcCd_b%Wu})2<-*D1IY(40~OGnQ)$YYCV4`=Ji|15 zI750ts0S!$cqlnGnHiZF8$Kac^<;U89HtD~f~Pew3{Sdsnx-h0EcDkP67U@^Ytx_y zqh+Oq>K0iO@J`(Zxp-XF;sDWYuc~E8%^RRtIb-6yY;~Nyr08{NlVFnM3LqYw990=6 zzFJmQnG>7=y8OvrtFN8lkG^=(CYPQ1?tppvm}|ylLOy)g4g7hgfE2}bwqs~B@dnv13QLQL=MqDWGrjo@#S{Uc8HZt(- z67$%6ErGLxBp0iQZ`gj2DnlIxliL=@;E^Yoaw8bowyT*?XUx$ zQv^$cS4de%@hX6tf@B9ZL#Kg#ggMcP?LTpKTBec2_UPoaAyr!dzcx&8E^M>%RmG&T z*e=bmeGn!Ttf{se@=$sXm>^P{m#^v&vGQbT0RM)Ty9}N&r_IZB3+4R4t!h_jMu5qV za0hviR0{7Q5At=ycIRXj$H^;!(U{-?JCZk*oRQEfpA#}PDq>8q04C5c)?j;SLIBdz z%>k}JHY1lcNG?<=sLf>UfC&VUKvTzsbVb5u#)Qp61cMqAynqRgpv-MQQ8i7ojRxo> zUli&}ITc|^!ipzgm>8w;Dcl$ZMDvdwABJ|6T2J41yTabW$c7hr0&8q^nT?jXrc$W2 z)Yw`Ijcxdmr~N+ic)T0IIszDKFEvCd&4rBsrSSqyxQ?d16zZe|S02Kou@q`q50#k= zJ%mYfDHJI+be2N>4`K31f|gRKjdFOhjzbs)o7Vze%Ym+yz_!Bh2FrR{*CV}4!*8E@ z^VCXY-y*-kBQCh+YlmOa*ItTtu0{Koqx~-hKj)h6Mmug=H>{g4+<2k5>-c*=UWtw` z2Jbeu-VEOe7m}a)8b3KcveJ6;{mJ*3*UsJQdG-8Sq;EOWw-Sl1MGh=S4tyLr`2OUg zzu0uHx0(^7YEn5P%%8P z${k#f#@^}wKv|8Z7K70FivM=+t=!6%J;jc_#l7Rj@R?O^;+wM$ta1bEt-F@4esFZP zb$rq9%u;@nD0ak(u@lAc$SQa8t}pQ7wHL1yuHE6D_=2n!Tjh2G?Q&RH>UwX_3MHaq z^sRFJpK#$X8hcAETT79iQgbJHhT883yiMLh@;(?2UkvV8np`?vNdtf$QAM+$&Aj11|^uyWs({|9!OuMMfEeWZZvp zMNSQ(KPUE&aqM3P17jZdU%iLMko#}Q18tkjKzvlxOePkweVI&Fn}&}#Bu-(1m1#66 ze;V+Sk61b7VmXR<;U datetime: + """Parse YYYY-MM-DD string into a datetime object.""" + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + return dt.replace(tzinfo=timezone.utc).astimezone() + except ValueError: + raise argparse.ArgumentTypeError( + f"Invalid date format: '{date_str}'. Expected YYYY-MM-DD." + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Track daily coding activity and write it to Obsidian." + ) + parser.add_argument( + "--vault", + dest="vault", + default=None, + help="Path to the Obsidian vault. Overrides OBSIDIAN_VAULT env var.", + ) + parser.add_argument( + "--code-dir", + dest="code_dirs", + action="append", + default=None, + help="Directory to scan for git repos (default: ~/Code). Can be given multiple times.", + ) + parser.add_argument( + "--daily-folder", + dest="daily_folder", + default=None, + help="Folder inside the vault for daily notes (default: Daily Notes). Overrides OBSIDIAN_DAILY_FOLDER env var.", + ) + parser.add_argument( + "--date", + dest="date", + type=parse_date, + default=None, + help="Date to summarize (YYYY-MM-DD). Defaults to today.", + ) + parser.add_argument( + "--no-zed", + dest="no_zed", + action="store_true", + help="Skip Zed editor tracking.", + ) + parser.add_argument( + "--no-git", + dest="no_git", + action="store_true", + help="Skip Git commit tracking.", + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Print the summary instead of writing to Obsidian.", + ) + + args = parser.parse_args() + + try: + vault_path = get_vault_path(args.vault) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if args.daily_folder: + daily_folder = args.daily_folder + else: + import os + + daily_folder = os.environ.get("OBSIDIAN_DAILY_FOLDER", "Daily Notes") + + code_dirs = args.code_dirs + if code_dirs: + code_dirs = [Path(d).expanduser().resolve() for d in code_dirs] + else: + code_dirs = [Path.home() / "Code"] + + target_date = args.date or datetime.now(timezone.utc).astimezone() + start_ts, end_ts = date_range(target_date) + + zed_projects = [] + git_repos = [] + + if not args.no_zed: + print("🔍 Reading Zed editor history…") + zed_projects = fetch_zed_activity(start_ts, end_ts, code_dirs=code_dirs) + print(f" Found {len(zed_projects)} project(s) with editor activity.") + + if not args.no_git: + print("🔍 Scanning git repositories…") + git_repos = fetch_git_activity(start_ts, end_ts, code_dirs=code_dirs) + print(f" Found {len(git_repos)} repo(s) with commits today.") + + if args.dry_run: + from utils.obsidian_writer import generate_markdown + + print("\n--- Daily Summary ---\n") + print(generate_markdown(zed_projects, git_repos, target_date)) + print("\n---------------------") + return + + note_path = write_daily_summary( + vault_path=vault_path, + zed_projects=zed_projects, + git_repos=git_repos, + daily_folder=daily_folder, + date=target_date, + ) + print(f"✅ Daily note updated: {note_path}") + + +if __name__ == "__main__": + main() diff --git a/dailySummary/utils/__init__.py b/dailySummary/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dailySummary/utils/__pycache__/__init__.cpython-314.pyc b/dailySummary/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24f6fcf108b779c108c4cec0b8216ca8edc167a4 GIT binary patch literal 170 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%UwSsKQ~oB zEj2wowMgGNKP6SapeR2pHMyi%KR>BBGbJ-IFSH~xr&vEFF*Bz!xHLC6v8YnN6f6`U qpP83g5+AQuP#_q zoDvHbKde?^w^c%ymQ>oRshX-FRqC#^60chcsjp50iaW}RN{dwWEi_%BzV)0t{@WC> zy!1+Q=ght5-h0lu=X~eP0e_>1Kzsj}TYuzt6Y?c~RGLsGHYgAUA`_EL5Sfc|CI@dm z$`A7oUc&bJ1P_b1C8S@tR3P(9pRtIXEjWw zwD@!)mohCURm~jDgazut8No0qbpi!qIYF&-!Zc8z-Bv48@Vu?xA=KSCG|5a8$$K#fCxiQPh0AVjq|}Ql?`SHz{-C>qwc7;eMN1WiHE&aWT|;Q>)BZPM!jV;AB*Z zH5u0VNDp5*KMCKRWSqG%GQ@l~Uwt1#qikw&b%!iJ##c^LzOzZsD%by8PusVcE>%fm zfht)gI;z%{g>G+km}G@9;brWp=%^v^w$#>EsEX|ax7>P;=I@K+ynQ54$-4bGrdIf`5O6ijfm5fM!=NKDd3l$wO34&2l zuWE*QLbCkl&YX`9Mo*8%$4*}u`5pjH`MCV{@4`M>Hl^AJBb4O;;k1mD-xHC}tETkz zrFeSIP^E(hnV^yU4+r#IN@1rds;SbnmU*r=5$Qs08SY~I@7|!IM)gcu&6udFmbjc! zBjHAdN0vvung#HM!>Q$>w&^S<)6lS*hqRPBs+&V7{aH$NYVo>Zxyn0Q!jx)SVp0bz zc4;7`qEXAK8F73IDK%sHhHT$yf{nA#pzhtOd5rU;3zNHL!mO95}KP_|~kW z)U;*p+RbZo?|$&^TClUY@AT(;KMxi6L|1}u6n$^N?5yueAb7`f%X7zn%U^6iv=Vr& z5PkgOJ-vas{+s=Cr#?7UYS}hB@}wnn=lIW$e{^zj$D@`5g^`l0W!1G~*|lTU)%sUg zYpE@~+ID!k?eN34-j(Jv%Wb_2-2JPcUcH~c(=p!xpR>+l^O-fTe|GxAlM5XWy*o=S z+ZQ^1alGhjU26=?zw+Vt7Y_e4z82h73LdySa`)6a;Z7WduKDPOAhh_`iO}f(w}*H` z>pa(RhJ!(`69yg5|NN_k1P3?+A@l(Wo$V4Iv^t@Ger|jXxV|YjLKXxryV+Tg;#F84 zc6avuAc#@S6k(1w!ply2>@Hb0W8JGbf$0@fn!vR`#cs(2vAOooMD|4yEx(q|L>OS{ zOlAvfqQt5x8z#!_dC5xVAWf8cv)o0l9&jC0|bAXMOpk>S# zIYtC{jor|pV~E_br`X*-wvMkQ$I|2Gaj-Ki0nRXx5AuVZ96TsZoQX+Kzyx!jI1msQzAf|eor||FE?oXi z@>j{b{GXinoS)G9GoQ|^_Qi^Qu?NF{8Ts?bL*HA4vn5Z{?eJpr!(itlPiJ8O!8kW} z| zOf8x#q3&gI3%H0sw9xZ2z3}>Dm#-8I&4(8Fg}#OG;^AV`-lBVN$-i~> z(tKj}+eL93vv!yp4L4e@cwEsZ;XjVSJ{rsRbCk|Ci6Z)WmX<=R9IPP!cx3G3i+*JztP*Uyz;8obZ?U T)hWoWU-pZUQv*k=C)zg#~p literal 0 HcmV?d00001 diff --git a/dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc b/dailySummary/utils/__pycache__/git_tracker.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c0386ae61561ad7d937c4519d227a3433bcb1b1 GIT binary patch literal 10650 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 literal 0 HcmV?d00001 diff --git a/dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc b/dailySummary/utils/__pycache__/obsidian_writer.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7cf0e65d875e5bf826ee498d5b3ec4ad5d7d572 GIT binary patch literal 6011 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 IQE7nx0f|tT5&!@I literal 0 HcmV?d00001 diff --git a/dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc b/dailySummary/utils/__pycache__/zed_tracker.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a94e2a9eba4611505db73ab9107826ea64044ece GIT binary patch literal 7757 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

)9efhgDzbVc2{@~B=I@@zx9-cPprt7XZ zBE>BK>-|2ZJo8#_#tFHIw%pZGea(#Jc#nqRt zzLa|@@2Z)xPupkXZ%w>0vFK{gdn#uF(}CQfhaMlW!ReW8`QFQUmv_cAZJMq4zCG{q z&Df@Gv$46JpX`2n_k2UgJy++doA_#1yu|FdYPn*W{)-!O&a!Cg$(!8Ers@S#^?h6I zT<@)IcXln>jshFZmenn!Zu`m;MDMsfd}(;0ik})@tP-#T|IHt*QIXnF<&>Q(a} zR`f9RdubR1@8?y6mE3!kTe04Iuw}4^{QK4&gLUjLy?%f{sB;f?=|5=a5dISe;VvD* zJ-(r4-LK5fp+?=Wsu_eE^-u?x;ebamMk4Vv{MRGKVFSe)ODEwkK>R~cQcZz%{r^Ky zEwt_Mb_p7pOpH>yrWoXl8Sr5uizL>rnp!LW`i=WFdvbr1=xl^a-hcKsxS{juiu8tzWP@rhbJ$wWfpS3T@g7 f0~I(>u|81oI~)8D@Vg!O#`Hx!=AUZrN{RSC&*R=a literal 0 HcmV?d00001 diff --git a/dailySummary/utils/config.py b/dailySummary/utils/config.py new file mode 100644 index 0000000..0929e3f --- /dev/null +++ b/dailySummary/utils/config.py @@ -0,0 +1,61 @@ +"""Configuration and common utilities for daily summary.""" + +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + + +def get_vault_path(cli_vault: Optional[str] = None) -> Path: + """Resolve the Obsidian vault path from CLI arg, env var, or default locations.""" + if cli_vault: + vault = Path(cli_vault).expanduser().resolve() + if not vault.exists(): + raise FileNotFoundError(f"Vault path does not exist: {vault}") + return vault + + env_vault = os.environ.get("OBSIDIAN_VAULT") + if env_vault: + vault = Path(env_vault).expanduser().resolve() + if vault.exists(): + return vault + + obsidian_dir = Path.home() / "Obsidian" + if obsidian_dir.exists(): + vaults = [d for d in obsidian_dir.iterdir() if d.is_dir()] + if len(vaults) == 1: + return vaults[0] + elif len(vaults) > 1: + raise RuntimeError( + f"Multiple vaults found in {obsidian_dir}. " + "Set OBSIDIAN_VAULT or use --vault." + ) + + raise RuntimeError( + "Could not determine Obsidian vault path. " + "Use --vault or set OBSIDIAN_VAULT environment variable." + ) + + +def get_daily_notes_folder(vault_path: Path) -> Path: + """Return the daily notes folder, defaulting to 'Daily Notes'.""" + folder = os.environ.get("OBSIDIAN_DAILY_FOLDER", "Daily Notes") + return vault_path / folder + + +def date_range(dt: datetime) -> tuple[int, int]: + """Return Unix timestamp range for the given datetime's date (start, end).""" + start = datetime(dt.year, dt.month, dt.day, tzinfo=dt.tzinfo or timezone.utc) + end = start + timedelta(days=1) + return int(start.timestamp()), int(end.timestamp()) + + +def format_duration(seconds: int) -> str: + """Format seconds as 'Xh Ym' or 'Ym'.""" + if seconds < 60: + return f"{seconds}s" + hours, remainder = divmod(seconds, 3600) + minutes = remainder // 60 + if hours: + return f"{hours}h {minutes}m" + return f"{minutes}m" diff --git a/dailySummary/utils/git_tracker.py b/dailySummary/utils/git_tracker.py new file mode 100644 index 0000000..2a7782d --- /dev/null +++ b/dailySummary/utils/git_tracker.py @@ -0,0 +1,225 @@ +"""Scan git repositories for today's commits.""" + +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + + +@dataclass +class CommitInfo: + hash: str + message: str + author: str + timestamp: int + files_changed: int + insertions: int + deletions: int + + +@dataclass +class RepoSummary: + name: str + path: Path + commits: List[CommitInfo] + total_insertions: int + total_deletions: int + total_files: int + + +def _run_git(args: List[str], cwd: Path, check: bool = True) -> str: + """Run a git command and return stdout.""" + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + check=check, + ) + if result.returncode != 0 and check: + raise subprocess.CalledProcessError( + result.returncode, + ["git"] + args, + output=result.stdout, + stderr=result.stderr, + ) + return result.stdout + + +def _parse_git_date(date_str: str) -> int: + """Parse a git date string into Unix timestamp. + + Handles both ISO ('2026-05-26 14:30:00 +0200') and + RFC ('Tue, 26 May 2026 14:30:00 +0200') formats. + """ + date_str = date_str.strip() + # Try ISO format first + for fmt in ("%Y-%m-%d %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S %z"): + try: + dt = datetime.strptime(date_str, fmt) + return int(dt.timestamp()) + except ValueError: + continue + return 0 + + +def _get_commits_for_date( + repo_path: Path, start_ts: int, end_ts: int +) -> List[CommitInfo]: + """Get commits in the given timestamp range.""" + # Use --since and --until with ISO format which git understands reliably + since = datetime.fromtimestamp(start_ts, tz=timezone.utc).isoformat() + until = datetime.fromtimestamp(end_ts, tz=timezone.utc).isoformat() + + log_format = "%H%x00%s%x00%an%x00%ad%x00" + try: + output = _run_git( + [ + "log", + f"--since={since}", + f"--until={until}", + f"--format={log_format}", + "--date=iso", + "--no-merges", + ], + cwd=repo_path, + check=False, + ) + except subprocess.CalledProcessError: + return [] + + if not output.strip(): + return [] + + commits = [] + entries = output.strip().split("\n") + for entry in entries: + parts = entry.split("\x00") + if len(parts) < 4: + continue + commit_hash, message, author, date_str = parts[0], parts[1], parts[2], parts[3] + ts = _parse_git_date(date_str) + + # Get stats for this commit + try: + stat_output = _run_git( + ["show", "--stat", "--format=", commit_hash], + cwd=repo_path, + check=False, + ) + except subprocess.CalledProcessError: + stat_output = "" + + files_changed, insertions, deletions = 0, 0, 0 + for line in stat_output.splitlines(): + line = line.strip() + if "file changed" in line or "files changed" in line: + # Parse line like "3 files changed, 10 insertions(+), 2 deletions(-)" + parts_stat = line.split(",") + for part in parts_stat: + part = part.strip() + if "changed" in part: + try: + files_changed = int(part.split()[0]) + except ValueError: + pass + elif "insertion" in part: + try: + insertions = int(part.split()[0]) + except ValueError: + pass + elif "deletion" in part: + try: + deletions = int(part.split()[0]) + except ValueError: + pass + + commits.append( + CommitInfo( + hash=commit_hash[:8], + message=message.strip(), + author=author.strip(), + timestamp=ts, + files_changed=files_changed, + insertions=insertions, + deletions=deletions, + ) + ) + + return commits + + +def _is_git_repo(path: Path) -> bool: + """Check if a directory is a git repository.""" + return (path / ".git").is_dir() + + +def find_git_repos(base_dir: Path, max_depth: int = 3) -> List[Path]: + """Find git repositories under base_dir up to max_depth levels.""" + repos = [] + base_dir = base_dir.expanduser().resolve() + if not base_dir.exists(): + return repos + + for depth in range(max_depth + 1): + pattern = "/".join(["*"] * depth) if depth > 0 else "." + if depth == 0: + if _is_git_repo(base_dir): + repos.append(base_dir) + continue + + for candidate in base_dir.glob(pattern): + if candidate.is_dir() and _is_git_repo(candidate): + repos.append(candidate) + + return repos + + +def fetch_git_activity( + start_ts: int, + end_ts: int, + code_dirs: Optional[List[Path]] = None, + max_depth: int = 3, +) -> List[RepoSummary]: + """Scan code directories and return git activity summaries.""" + if code_dirs is None: + code_dirs = [Path.home() / "Code"] + + all_repos = [] + for d in code_dirs: + all_repos.extend(find_git_repos(d, max_depth=max_depth)) + + # Deduplicate + seen = set() + unique_repos = [] + for r in all_repos: + resolved = r.resolve() + if resolved not in seen: + seen.add(resolved) + unique_repos.append(resolved) + + summaries = [] + for repo_path in unique_repos: + commits = _get_commits_for_date(repo_path, start_ts, end_ts) + if not commits: + continue + + total_insertions = sum(c.insertions for c in commits) + total_deletions = sum(c.deletions for c in commits) + total_files = sum(c.files_changed for c in commits) + + summaries.append( + RepoSummary( + name=repo_path.name, + path=repo_path, + commits=commits, + total_insertions=total_insertions, + total_deletions=total_deletions, + total_files=total_files, + ) + ) + + # Sort by number of commits descending + summaries.sort(key=lambda r: -len(r.commits)) + return summaries diff --git a/dailySummary/utils/obsidian_writer.py b/dailySummary/utils/obsidian_writer.py new file mode 100644 index 0000000..b775875 --- /dev/null +++ b/dailySummary/utils/obsidian_writer.py @@ -0,0 +1,112 @@ +"""Write or append daily summaries to Obsidian notes.""" + +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from .git_tracker import RepoSummary +from .zed_tracker import ProjectSummary + +DAILY_NOTE_FORMAT = "%Y-%m-%d" +SECTION_HEADER = "## Coding Work" + + +def _format_time(ts: int) -> str: + """Format a Unix timestamp as HH:MM.""" + dt = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone() + return dt.strftime("%H:%M") + + +def generate_markdown( + zed_projects: List[ProjectSummary], + git_repos: List[RepoSummary], + date: datetime, +) -> str: + """Generate the markdown content for the daily summary.""" + lines = [] + lines.append(SECTION_HEADER) + lines.append("") + + if not zed_projects and not git_repos: + lines.append("No coding activity tracked today. 🌴") + return "\n".join(lines) + + if zed_projects: + lines.append("### Zed Editors") + lines.append("") + for project in zed_projects: + langs = ( + f" ({', '.join(sorted(project.languages))})" + if project.languages + else "" + ) + lines.append( + f"- **{project.project}**{langs} — {len(project.files)} file(s)" + ) + # Show up to 5 most recent files + for path, mtime in project.files[:5]: + time_str = _format_time(mtime) + lines.append(f" - `{Path(path).name}` at {time_str}") + if len(project.files) > 5: + lines.append(f" - *…and {len(project.files) - 5} more*") + lines.append("") + + if git_repos: + lines.append("### Git Commits") + lines.append("") + for repo in git_repos: + lines.append( + f"- **{repo.name}** — {len(repo.commits)} commit(s), " + f"+{repo.total_insertions}/-{repo.total_deletions} lines, " + f"{repo.total_files} file(s)" + ) + for commit in repo.commits: + time_str = _format_time(commit.timestamp) + lines.append(f" - `{commit.hash}` {commit.message} — {time_str}") + lines.append("") + + return "\n".join(lines) + + +# Backward compatibility alias +_generate_markdown = generate_markdown + + +def write_daily_summary( + vault_path: Path, + zed_projects: List[ProjectSummary], + git_repos: List[RepoSummary], + daily_folder: str = "Daily Notes", + date: datetime = None, +) -> Path: + """Write or append a coding summary to the Obsidian daily note.""" + if date is None: + date = datetime.now(timezone.utc).astimezone() + + note_dir = vault_path / daily_folder + note_dir.mkdir(parents=True, exist_ok=True) + + note_name = date.strftime(DAILY_NOTE_FORMAT) + note_path = note_dir / f"{note_name}.md" + + content = _generate_markdown(zed_projects, git_repos, date) + + if note_path.exists(): + existing = note_path.read_text(encoding="utf-8") + # If section already exists, replace it; otherwise append + if SECTION_HEADER in existing: + before, _, after = existing.partition(SECTION_HEADER) + # Find the next section header or end of file + next_section_idx = after.find("\n## ") + if next_section_idx != -1: + after = after[next_section_idx:] + else: + after = "" + new_content = before.rstrip() + "\n\n" + content + "\n" + after.lstrip() + else: + new_content = existing.rstrip() + "\n\n" + content + "\n" + else: + new_content = f"# {note_name}\n\n" + content + "\n" + + note_path.write_text(new_content, encoding="utf-8") + return note_path diff --git a/dailySummary/utils/zed_tracker.py b/dailySummary/utils/zed_tracker.py new file mode 100644 index 0000000..36c9cac --- /dev/null +++ b/dailySummary/utils/zed_tracker.py @@ -0,0 +1,140 @@ +"""Read recent editor activity from Zed's SQLite database.""" + +import sqlite3 +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +@dataclass +class EditorSession: + path: str + mtime: int + language: Optional[str] + + +@dataclass +class ProjectSummary: + project: str + files: List[Tuple[str, int]] # (relative_path, mtime) + languages: set + + +def _find_zed_db() -> Optional[Path]: + """Locate the most recent Zed stable database.""" + zed_db_dir = Path.home() / ".local" / "share" / "zed" / "db" + if not zed_db_dir.exists(): + return None + + candidates = [] + for sub in zed_db_dir.iterdir(): + db_file = sub / "db.sqlite" + if db_file.exists(): + # Prefer stable, then preview, then anything else + priority = 0 + if "stable" in sub.name: + priority = 2 + elif "preview" in sub.name: + priority = 1 + candidates.append((priority, db_file.stat().st_mtime, db_file)) + + if not candidates: + return None + + candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) + return candidates[0][2] + + +def _resolve_project(file_path: Path, code_dirs: List[Path]) -> Optional[str]: + """Return the project name for a file path based on code directories.""" + for code_dir in code_dirs: + try: + rel = file_path.relative_to(code_dir) + parts = rel.parts + if parts: + return parts[0] + except ValueError: + continue + # Fallback: use parent directory name + return file_path.parent.name if file_path.parent != Path("/") else None + + +def fetch_zed_activity( + start_ts: int, + end_ts: int, + code_dirs: Optional[List[Path]] = None, +) -> List[ProjectSummary]: + """Fetch editor activity from Zed for the given time range.""" + db_path = _find_zed_db() + if not db_path: + return [] + + if code_dirs is None: + code_dirs = [Path.home() / "Code"] + code_dirs = [d.expanduser().resolve() for d in code_dirs if d.exists()] + + sessions: List[EditorSession] = [] + try: + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + cursor.execute( + """ + SELECT path, mtime_seconds, language + FROM editors + WHERE mtime_seconds >= ? AND mtime_seconds < ? + ORDER BY mtime_seconds DESC + """, + (start_ts, end_ts), + ) + for row in cursor.fetchall(): + path_blob, mtime, language = row + if not path_blob: + continue + try: + path_str = ( + path_blob.decode("utf-8") + if isinstance(path_blob, bytes) + else str(path_blob) + ) + except (UnicodeDecodeError, AttributeError): + continue + sessions.append( + EditorSession(path=path_str, mtime=mtime, language=language) + ) + except sqlite3.Error as e: + print(f"Warning: could not read Zed database: {e}") + return [] + + # Group by project + by_project: Dict[str, List[EditorSession]] = defaultdict(list) + for s in sessions: + fp = Path(s.path) + project = _resolve_project(fp, code_dirs) + if not project: + project = fp.parent.name + by_project[project].append(s) + + summaries = [] + for project, items in by_project.items(): + files = [] + seen = set() + languages = set() + for item in items: + if item.language: + languages.add(item.language) + rel = item.path + # Store unique files with their latest mtime + key = rel + if key not in seen: + seen.add(key) + files.append((rel, item.mtime)) + # Sort by mtime descending + files.sort(key=lambda x: x[1], reverse=True) + summaries.append( + ProjectSummary(project=project, files=files, languages=languages) + ) + + # Sort projects by number of files, then by latest mtime + summaries.sort(key=lambda s: (-len(s.files), -s.files[0][1] if s.files else 0)) + return summaries