diff --git a/docs/schema.json b/docs/schema.json index 61caff1..6ec450b 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -79,6 +79,16 @@ ] }, + "timezone": { + "type": "string", + "markdownDescription": "IANA timezone name used to render dates. Git timestamps are resolved into calendar days in this timezone, and Front Matter values without a timezone are interpreted as being in it. Values that carry an explicit timezone are left untouched. Defaults to the build machine's local timezone.", + "examples": [ + "Asia/Shanghai", + "UTC", + "America/New_York" + ] + }, + "date_format": { "type": "string", "default": "%Y-%m-%d", diff --git a/mkdocs_document_dates/cache_manager.py b/mkdocs_document_dates/cache_manager.py index dd00979..7d4c5c4 100644 --- a/mkdocs_document_dates/cache_manager.py +++ b/mkdocs_document_dates/cache_manager.py @@ -122,6 +122,90 @@ def find_mkdocs_projects() -> dict[Path, Path]: return projects +def get_renamed_files(docs_dir: Path) -> dict: + """获取本次提交中被重命名/移动的 markdown 文件,返回 {旧路径: 新路径} + + 路径均相对 docs_dir,与 JSONL 缓存的 key 格式一致。 + 在 pre-commit 阶段对比 HEAD 与暂存区,依靠 git 的 -M 相似度检测识别移动, + 因此 `git mv` 和「手动 mv + git add」两种方式都能覆盖。 + + 这里刻意以仓库根为视角(--no-relative)再自行过滤,只保留两端都在 docs_dir + 内的重命名。若改用 --relative,git 只能看见 docs_dir 内部的增删,会把 + 「移出去的文件」和「移进来的相似文件」误配成一次重命名,导致创建日期张冠李戴。 + """ + renames = {} + try: + git_root = Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=docs_dir, env=_clean_git_env(), encoding="utf-8" + ).strip()).resolve() + rel_docs = docs_dir.resolve().relative_to(git_root) + # docs_dir 就是仓库根时前缀为空,否则形如 "docs/" + prefix = "" if rel_docs == Path(".") else rel_docs.as_posix() + "/" + + # --no-relative 抵消用户可能配置的 diff.relative=true + cmd = [ + "git", "-c", "core.quotepath=false", + "diff", "--cached", "-M", "--name-status", "-z", "--no-relative", + ] + result = subprocess.run(cmd, cwd=docs_dir, env=_clean_git_env(), + capture_output=True, encoding="utf-8") + if result.returncode != 0 or not result.stdout: + return renames + + # -z 输出格式: 重命名为 "R\0旧路径\0新路径",其余为 "\0路径" + fields = result.stdout.split("\0") + i = 0 + while i < len(fields): + status = fields[i] + if not status: + i += 1 + continue + # R(重命名) 和 C(复制) 均为三字段,但只有 R 需要迁移 + if status[0] in ("R", "C"): + if i + 2 < len(fields): + old_path, new_path = fields[i + 1], fields[i + 2] + # 只保留两端都在 docs_dir 内的 markdown 重命名, + # 跨 docs_dir 边界的移动没有可继承的创建日期 + if (status[0] == "R" + and old_path.endswith(".md") and new_path.endswith(".md") + and old_path.startswith(prefix) and new_path.startswith(prefix)): + renames[old_path[len(prefix):]] = new_path[len(prefix):] + i += 3 + else: + i += 2 + except Exception as e: + logger.warning(f"Failed to detect renamed files in {docs_dir}: {e}") + return renames + +def migrate_renamed_entries(dates_cache: dict, docs_dir: Path) -> bool: + """把被重命名文件的创建日期从旧路径迁移到新路径 + + 不这样做的话,新路径不在缓存中,会被当作新文件重新取创建时间 + (Linux 上即文件的 mtime,也就是重命名的那一刻),原始创建日期就丢了。 + """ + renames = get_renamed_files(docs_dir) + if not renames: + return False + + # 分两阶段:先全部摘出,再统一落位。 + # 这样 a→b 与 b→a 这类互换也能正确处理(单阶段时会因目标已存在而互相阻塞) + pending = {} + for old_path, new_path in renames.items(): + if old_path in dates_cache: + pending[new_path] = (old_path, dates_cache.pop(old_path)) + + migrated = False + for new_path, (old_path, info) in pending.items(): + # 目标已被别的条目占用时不覆盖(重命名恰好盖掉一个已存在的文件) + if new_path in dates_cache: + logger.info(f"Skipped migration, target already exists: {old_path} -> {new_path}") + continue + dates_cache[new_path] = info + migrated = True + logger.info(f"Migrated created date: {old_path} -> {new_path}") + return migrated + def setup_gitattributes(docs_dir: Path): try: gitattributes_path = docs_dir / ".gitattributes" @@ -187,6 +271,10 @@ def update_cache(): jsonl_cache_file = docs_dir / ".dates_cache.jsonl" jsonl_dates_cache = read_jsonl_cache(jsonl_cache_file) + # 迁移重命名/移动文件的创建日期(须在下面的循环之前,否则会被当作新文件处理) + if jsonl_dates_cache: + project_updated |= migrate_renamed_entries(jsonl_dates_cache, docs_dir) + # 根据 git已跟踪的文件来更新 for rel_path in tracked_files: try: diff --git a/mkdocs_document_dates/plugin.py b/mkdocs_document_dates/plugin.py index a14afee..fb8256e 100644 --- a/mkdocs_document_dates/plugin.py +++ b/mkdocs_document_dates/plugin.py @@ -29,6 +29,7 @@ class DocumentDatesPlugin(BasePlugin): config_scheme = ( ('type', config_options.Type(str, default='date')), ('locale', config_options.Type(str, default='')), + ('timezone', config_options.Type(str, default='')), ('date_format', config_options.Type(str, default='%Y-%m-%d')), ('time_format', config_options.Type(str, default='%H:%M:%S')), ('position', config_options.Type(str, default='top')), @@ -51,9 +52,34 @@ def __init__(self): self.recent_docs_html = None self.recent_enable = False self._exclude_patterns = [] + self.tz = None + + def _resolve_timezone(self, name: str): + """解析配置的时区名,留空则回退到构建机器的本地时区(保持旧行为) + + 这个时区同时承担两个角色: + 1. 把 git 的时间戳(绝对时刻)落地成"哪一天" + 2. 解释 Front Matter 中不带时区的时间(视作作者所在时区) + 显式写了时区的 Front Matter 值不受影响,以它自己的为准。 + """ + local_tz = datetime.now().astimezone().tzinfo + if not name: + return local_tz + try: + # zoneinfo 返回的 tzinfo 可安全用于 replace(),优先使用 + from zoneinfo import ZoneInfo + return ZoneInfo(name) + except Exception: + try: + from babel.dates import get_timezone + return get_timezone(name) + except Exception as e: + logger.warning(f"Unknown timezone '{name}', falling back to local timezone: {e}") + return local_tz def on_config(self, config): docs_dir_path = Path(config.docs_dir) + self.tz = self._resolve_timezone(self.config['timezone']) # 加载 author 配置 authors_file = None @@ -152,6 +178,17 @@ def on_config(self, config): @event_priority(50) def on_files(self, files, config): self.data_cached = load_dates_and_authors(Path(config.docs_dir), files) + + # 缓存里是 UTC,统一转成配置的时区。 + # data_cached 是对外的日期 API(例如 MaterialX 的 blog 插件会直接读它来定 + # 文章日期),转换只改变呈现方式、不改变绝对时刻,排序与比较都不受影响。 + if self.tz: + for info in self.data_cached.values(): + for key in ('created', 'updated'): + value = info.get(key) + if isinstance(value, datetime): + info[key] = value.astimezone(self.tz) + return files @event_priority(50) @@ -179,11 +216,11 @@ def on_page_markdown(self, markdown, page: Page, config, files): if not authors: authors = self._load_author_cached(rel_path, page, config) - # 注入数据到模板 (utc datetime -> local datetime) + # 注入数据到模板 (utc datetime -> configured timezone) page.meta["document_dates"] = { "dates": { - "created": created.astimezone().isoformat() if created else None, - "updated": updated.astimezone().isoformat() if updated else None, + "created": created.astimezone(self.tz).isoformat() if created else None, + "updated": updated.astimezone(self.tz).isoformat() if updated else None, }, "authors": authors } @@ -227,7 +264,7 @@ def on_env(self, env, config, files): # 获取最近更新的文档数据 recent_exclude_patterns = compile_exclude_patterns(exclude_list) - recently_updated_docs = get_recently_updated_files(self.data_cached, files, recent_exclude_patterns, limit, self.recent_enable, prefix, wpm, wpm_cjk) + recently_updated_docs = get_recently_updated_files(self.data_cached, files, recent_exclude_patterns, limit, self.recent_enable, prefix, wpm, wpm_cjk, self.tz) # 将数据注入到 config['extra'] 中供全局访问 if not config.get('extra', {}).get("recently_updated_docs", {}): @@ -306,10 +343,10 @@ def _load_meta_date(self, meta, field_names): # 移除首尾可能存在的单双引号 date_str = str(meta[field]).strip("'\"") dt = datetime.fromisoformat(date_str) - # 如果没时区,则当成本地时间,再转 UTC + # 没写时区就按配置的时区解释(即作者所在时区),再转 UTC; + # 显式写了时区的以它自己的为准 if dt.tzinfo is None: - local_tz = datetime.now().astimezone().tzinfo - dt = dt.replace(tzinfo=local_tz) + dt = dt.replace(tzinfo=self.tz or datetime.now().astimezone().tzinfo) return dt.astimezone(timezone.utc) # return datetime.fromisoformat(date_str).astimezone() except Exception: @@ -411,6 +448,7 @@ def _formatting_date(self, date: datetime): return format_datetime( date, format=fmt, + tzinfo=self.tz, locale=locale ) @@ -436,7 +474,7 @@ def build_time_icon(time_obj: datetime, icon: str): return ( f"" f"" - f"" + f"" f"" ) diff --git a/mkdocs_document_dates/utils.py b/mkdocs_document_dates/utils.py index 51fcfc6..f0bb1ff 100644 --- a/mkdocs_document_dates/utils.py +++ b/mkdocs_document_dates/utils.py @@ -233,7 +233,7 @@ def load_git_last_updated_dates(docs_dir_path: Path): return doc_mtime_map # 建议在 on_page_markdown 之后的全局事件中调用,因为需要读取 page.meta 中的信息 -def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list: list, limit: int = 10, recent_enable: bool = False, prefix: str = "", wpm: int = DEFAULT_WPM, wpm_cjk: int = DEFAULT_WPM_CJK): +def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list: list, limit: int = 10, recent_enable: bool = False, prefix: str = "", wpm: int = DEFAULT_WPM, wpm_cjk: int = DEFAULT_WPM_CJK, tz=None): recently_updated_results = [] if recent_enable: files_meta = [] @@ -288,8 +288,8 @@ def get_recently_updated_files(existing_dates: dict, files: Files, exclude_list: recently_updated_results = heapq.nlargest(limit, files_meta, key=itemgetter("updated_ts")) for doc in recently_updated_results: - # timestamp -> utc datetime -> local datetime - dt = datetime.fromtimestamp(doc["updated_ts"], tz=timezone.utc).astimezone() + # timestamp -> utc datetime -> configured timezone (tz=None 时为本地时区) + dt = datetime.fromtimestamp(doc["updated_ts"], tz=timezone.utc).astimezone(tz) doc["updated_dt"] = dt.isoformat() doc["updated"] = dt.date().isoformat()