feat(wrapped): 添加最早最晚消息展示功能

This commit is contained in:
2977094657
2026-02-01 15:26:33 +08:00
parent e5ba16abc0
commit 18957be354
4 changed files with 928 additions and 10 deletions

View File

@@ -62,21 +62,116 @@
<span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00 <span class="wrapped-number text-[#07C160] font-semibold">{{ pad2(mostActiveHour) }}</span>:00
最活跃 最活跃
</template> </template>
<!-- 最早最晚消息描述按一天中的时刻 -->
<template v-if="earliestSent && latestSent && totalMessages > 0">
<template v-if="sameMomentTarget">
最先想起的是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最后放不下的也还是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
</template>
<template v-else>
<template v-if="sameMomentDate">
{{ earliestDateLabel }}最早的一条发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="!hasMomentDates">
最早的一条发给了
<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条发给了
<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 0">
最早的一条{{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条{{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 1">
最早的收件人是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
最晚的收件人是<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
</template>
<template v-else-if="momentVariant === 2">
{{ earliestDateLabel }}你把消息发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
{{ latestDateLabel }}你又发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 3">
最早与最晚分别写给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
</template>
<template v-else>
最早的一条落在 {{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条落在 {{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
</template>
</template>
</template>
</p>
<!-- 今年第一条/最后一条消息按日期时间戳 -->
<p v-if="yearFirstSent && totalMessages > 0" class="mt-2">
今年的第一条消息<span class="wrapped-number text-[#07C160] font-semibold">{{ yearFirstDateLabel }} {{ yearFirstSent.time }}</span>发给了
<img
v-if="yearFirstSent.avatarUrl"
:src="yearFirstSent.avatarUrl"
:alt="yearFirstSent.displayName"
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearFirstSent.displayName }}</span>{{ yearFirstSent.content || '...' }}<template v-if="yearLastSent">
最后一条消息<span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastDateLabel }} {{ yearLastSent.time }}</span>发给了
<img
v-if="yearLastSent.avatarUrl"
:src="yearLastSent.avatarUrl"
:alt="yearLastSent.displayName"
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastSent.displayName }}</span>{{ yearLastSent.content || '...' }}</template>
<template v-if="sameYearTarget">
<span class="text-[#7F7F7F]">从年初到年末始终如一</span>
</template>
</p> </p>
</div> </div>
</template> </template>
<WeekdayHourHeatmap <!-- 内容区域上下布局 -->
:weekday-labels="card.data?.weekdayLabels" <div class="flex flex-col gap-4">
:hour-labels="card.data?.hourLabels" <!-- 上部两个聊天回放水平排列 -->
:matrix="card.data?.matrix" <div v-if="earliestSent || latestSent" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
:total-messages="card.data?.totalMessages || 0" <ChatReplayAnimation
/> v-if="earliestSent"
:time="earliestSent.time"
:date="earliestSent.date"
:display-name="earliestSent.displayName"
:masked-name="earliestSent.maskedName"
:avatar-url="earliestSent.avatarUrl"
:content="earliestSent.content"
label="最早的一条"
:delay="0"
/>
<ChatReplayAnimation
v-if="latestSent"
:time="latestSent.time"
:date="latestSent.date"
:display-name="latestSent.displayName"
:masked-name="latestSent.maskedName"
:avatar-url="latestSent.avatarUrl"
:content="latestSent.content"
label="最晚的一条"
:delay="600"
/>
</div>
<!-- 下部热力图全宽 -->
<div class="w-full">
<WeekdayHourHeatmap
:weekday-labels="card.data?.weekdayLabels"
:hour-labels="card.data?.hourLabels"
:matrix="card.data?.matrix"
:total-messages="card.data?.totalMessages || 0"
/>
</div>
</div>
</WrappedCardShell> </WrappedCardShell>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import ChatReplayAnimation from '~/components/wrapped/visualizations/ChatReplayAnimation.vue'
const props = defineProps({ const props = defineProps({
card: { type: Object, required: true }, card: { type: Object, required: true },
@@ -98,6 +193,87 @@ const matrix = computed(() => {
const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0)) const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0))
const earliestSent = computed(() => {
const o = props.card?.data?.earliestSent
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const latestSent = computed(() => {
const o = props.card?.data?.latestSent
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const _formatDateLabel = (ymd) => {
const s = String(ymd || '').trim()
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!m) return s
const mm = String(Number(m[2]))
const dd = String(Number(m[3]))
return `${mm}${dd}`
}
const earliestDateLabel = computed(() => _formatDateLabel(earliestSent.value?.date))
const latestDateLabel = computed(() => _formatDateLabel(latestSent.value?.date))
const hasMomentDates = computed(() => Boolean(earliestDateLabel.value && latestDateLabel.value))
const sameMomentDate = computed(() => hasMomentDates.value && earliestDateLabel.value === latestDateLabel.value)
const sameMomentTarget = computed(() => {
const a = earliestSent.value
const b = latestSent.value
if (!a || !b) return false
const ua = String(a.username || '').trim()
const ub = String(b.username || '').trim()
if (ua && ub) return ua === ub
// Fallback: compare display names if username missing.
const da = String(a.displayName || '').trim()
const db = String(b.displayName || '').trim()
return !!da && !!db && da === db
})
const momentVariant = computed(() => {
const a = earliestSent.value
const b = latestSent.value
if (!a || !b) return 0
const t0 = Number(a.timestamp || 0)
const t1 = Number(b.timestamp || 0)
const seed = (Number.isFinite(t0) ? t0 : 0) ^ (Number.isFinite(t1) ? t1 : 0) ^ 0x9e3779b9
// 5 variants (0..4)
return Math.abs(seed) % 5
})
// 今年第一条/最后一条消息(按日期时间戳排序)
const yearFirstSent = computed(() => {
const o = props.card?.data?.yearFirstSent
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const yearLastSent = computed(() => {
const o = props.card?.data?.yearLastSent
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const yearFirstDateLabel = computed(() => _formatDateLabel(yearFirstSent.value?.date))
const yearLastDateLabel = computed(() => _formatDateLabel(yearLastSent.value?.date))
const sameYearTarget = computed(() => {
const a = yearFirstSent.value
const b = yearLastSent.value
if (!a || !b) return false
const ua = String(a.username || '').trim()
const ub = String(b.username || '').trim()
if (ua && ub) return ua === ub
// Fallback: compare display names if username missing.
const da = String(a.displayName || '').trim()
const db = String(b.displayName || '').trim()
return !!da && !!db && da === db
})
const mostActiveHour = computed(() => { const mostActiveHour = computed(() => {
if (!matrix.value || !Array.isArray(matrix.value) || matrix.value.length < 7) return null if (!matrix.value || !Array.isArray(matrix.value) || matrix.value.length < 7) return null

View File

@@ -8,7 +8,8 @@
</div> </div>
<div class="mt-4 overflow-x-auto" data-wrapped-scroll-x> <div class="mt-4 overflow-x-auto" data-wrapped-scroll-x>
<div class="min-w-[720px]"> <!-- Keep original style, but slightly shrink the overall grid width (thus shrinking cells). -->
<div class="min-w-[520px] max-w-[600px] mx-auto">
<div class="grid gap-[3px] [grid-template-columns:24px_1fr] text-[11px] text-[#00000066] mb-2"> <div class="grid gap-[3px] [grid-template-columns:24px_1fr] text-[11px] text-[#00000066] mb-2">
<div></div> <div></div>
<div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]"> <div class="grid gap-[3px] [grid-template-columns:repeat(24,minmax(0,1fr))]">

View File

@@ -1,14 +1,24 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import re
import sqlite3 import sqlite3
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Optional
from ...chat_search_index import get_chat_search_index_db_path from ...chat_search_index import get_chat_search_index_db_path
from ...chat_helpers import _iter_message_db_paths, _quote_ident from ...chat_helpers import (
_build_avatar_url,
_iter_message_db_paths,
_load_contact_rows,
_pick_avatar_url,
_pick_display_name,
_quote_ident,
_row_to_search_hit,
)
from ...logging_config import get_logger from ...logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -17,6 +27,8 @@ logger = get_logger(__name__)
_WEEKDAY_LABELS_ZH = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] _WEEKDAY_LABELS_ZH = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
_HOUR_LABELS = [f"{h:02d}" for h in range(24)] _HOUR_LABELS = [f"{h:02d}" for h in range(24)]
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}")
@dataclass(frozen=True) @dataclass(frozen=True)
class WeekdayHourHeatmap: class WeekdayHourHeatmap:
@@ -26,6 +38,18 @@ class WeekdayHourHeatmap:
total_messages: int total_messages: int
@dataclass(frozen=True)
class _SentMomentRef:
"""Lightweight reference to a sent message (for earliest/latest moment extraction)."""
ts: int
score: int
username: str
db_stem: str
table_name: str
local_id: int
def _get_time_personality(hour: int) -> str: def _get_time_personality(hour: int) -> str:
if 5 <= hour <= 8: if 5 <= hour <= 8:
return "early_bird" return "early_bird"
@@ -81,6 +105,638 @@ def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
return start, end return start, end
def _mask_name(name: str) -> str:
s = str(name or "").strip()
if not s:
return ""
if len(s) == 1:
return "*"
if len(s) == 2:
return s[0] + "*"
return s[0] + ("*" * (len(s) - 2)) + s[-1]
def _list_session_usernames(session_db_path: Path) -> list[str]:
if not session_db_path.exists():
return []
conn = sqlite3.connect(str(session_db_path))
try:
try:
rows = conn.execute("SELECT username FROM SessionTable").fetchall()
except sqlite3.OperationalError:
rows = conn.execute("SELECT username FROM Session").fetchall()
except Exception:
rows = []
finally:
conn.close()
out: list[str] = []
for r in rows:
if not r or not r[0]:
continue
u = str(r[0]).strip()
if u:
out.append(u)
return out
def _compute_year_first_last_from_index(
*,
account_dir: Path,
year: int,
sender_username: str,
) -> tuple[Optional[_SentMomentRef], Optional[_SentMomentRef]]:
"""Find the chronologically first and last sent messages of the year (by timestamp)."""
start_ts, end_ts = _year_range_epoch_seconds(year)
sender = str(sender_username or "").strip()
if not sender:
return None, None
index_path = get_chat_search_index_db_path(account_dir)
if not index_path.exists():
return None, None
conn = sqlite3.connect(str(index_path))
try:
has_fts = (
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
is not None
)
if not has_fts:
return None, None
ts_expr = (
"CASE "
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
"ELSE CAST(create_time AS INTEGER) "
"END"
)
where = (
f"{ts_expr} >= ? AND {ts_expr} < ? "
"AND db_stem NOT LIKE 'biz_message%' "
"AND sender_username = ? "
"AND CAST(local_type AS INTEGER) != 10000"
)
base_sql = (
f"SELECT {ts_expr} AS ts, username, db_stem, table_name, CAST(local_id AS INTEGER) AS local_id "
"FROM message_fts "
f"WHERE {where} "
)
def row_to_ref(r: Any) -> Optional[_SentMomentRef]:
if not r:
return None
try:
ts = int(r[0] or 0)
except Exception:
ts = 0
username = str(r[1] or "").strip()
db_stem = str(r[2] or "").strip()
table_name = str(r[3] or "").strip()
try:
local_id = int(r[4] or 0)
except Exception:
local_id = 0
if ts <= 0 or not username or not db_stem or not table_name or local_id <= 0:
return None
return _SentMomentRef(
ts=int(ts),
score=0, # Not used for chronological ordering
username=username,
db_stem=db_stem,
table_name=table_name,
local_id=int(local_id),
)
params = (start_ts, end_ts, sender)
sql_first = base_sql + "ORDER BY ts ASC LIMIT 1"
sql_last = base_sql + "ORDER BY ts DESC LIMIT 1"
first_ref = row_to_ref(conn.execute(sql_first, params).fetchone())
last_ref = row_to_ref(conn.execute(sql_last, params).fetchone())
return first_ref, last_ref
except Exception:
return None, None
finally:
try:
conn.close()
except Exception:
pass
def _compute_year_first_last_fallback(
*,
account_dir: Path,
year: int,
sender_username: str,
) -> tuple[Optional[_SentMomentRef], Optional[_SentMomentRef]]:
"""Fallback: find chronologically first/last sent messages when no search index."""
start_ts, end_ts = _year_range_epoch_seconds(year)
sender = str(sender_username or "").strip()
if not sender:
return None, None
session_usernames = _list_session_usernames(account_dir / "session.db")
md5_to_username: dict[str, str] = {}
table_to_username: dict[str, str] = {}
for u in session_usernames:
md5_hex = hashlib.md5(u.encode("utf-8")).hexdigest().lower()
md5_to_username[md5_hex] = u
table_to_username[f"msg_{md5_hex}"] = u
table_to_username[f"chat_{md5_hex}"] = u
def resolve_username_from_table(table_name: str) -> Optional[str]:
ln = str(table_name or "").lower()
u = table_to_username.get(ln)
if u:
return u
m = _MD5_HEX_RE.search(ln)
if m:
return md5_to_username.get(m.group(0).lower())
return None
db_paths = _iter_message_db_paths(account_dir)
db_paths = [p for p in db_paths if not p.name.lower().startswith("biz_message")]
ts_expr = (
"CASE WHEN create_time > 1000000000000 THEN CAST(create_time/1000 AS INTEGER) ELSE create_time END"
)
best_first: Optional[_SentMomentRef] = None
best_last: Optional[_SentMomentRef] = None
for db_path in db_paths:
if not db_path.exists():
continue
conn: sqlite3.Connection | None = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
try:
r2 = conn.execute("SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", (sender,)).fetchone()
sender_rowid = int(r2[0]) if r2 and r2[0] is not None else None
except Exception:
sender_rowid = None
if sender_rowid is None:
continue
tables = _list_message_tables(conn)
if not tables:
continue
for table_name in tables:
username = resolve_username_from_table(table_name)
if not username:
continue
qt = _quote_ident(table_name)
params = (start_ts, end_ts, int(sender_rowid))
sql_base = (
f"SELECT local_id, {ts_expr} AS ts "
f"FROM {qt} "
f"WHERE {ts_expr} >= ? AND {ts_expr} < ? "
"AND real_sender_id = ? "
"AND local_type != 10000 "
)
sql_first = sql_base + "ORDER BY ts ASC LIMIT 1"
sql_last = sql_base + "ORDER BY ts DESC LIMIT 1"
try:
r_first = conn.execute(sql_first, params).fetchone()
except Exception:
r_first = None
if r_first:
try:
local_id = int(r_first["local_id"] or 0)
ts = int(r_first["ts"] or 0)
except Exception:
local_id, ts = 0, 0
if local_id > 0 and ts > 0:
ref = _SentMomentRef(
ts=int(ts),
score=0,
username=str(username),
db_stem=str(db_path.stem),
table_name=str(table_name),
local_id=int(local_id),
)
if best_first is None or ref.ts < best_first.ts:
best_first = ref
try:
r_last = conn.execute(sql_last, params).fetchone()
except Exception:
r_last = None
if r_last:
try:
local_id = int(r_last["local_id"] or 0)
ts = int(r_last["ts"] or 0)
except Exception:
local_id, ts = 0, 0
if local_id > 0 and ts > 0:
ref = _SentMomentRef(
ts=int(ts),
score=0,
username=str(username),
db_stem=str(db_path.stem),
table_name=str(table_name),
local_id=int(local_id),
)
if best_last is None or ref.ts > best_last.ts:
best_last = ref
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
return best_first, best_last
def _compute_sent_moment_refs_from_index(
*,
account_dir: Path,
year: int,
sender_username: str,
) -> tuple[Optional[_SentMomentRef], Optional[_SentMomentRef]]:
start_ts, end_ts = _year_range_epoch_seconds(year)
sender = str(sender_username or "").strip()
if not sender:
return None, None
index_path = get_chat_search_index_db_path(account_dir)
if not index_path.exists():
return None, None
conn = sqlite3.connect(str(index_path))
try:
has_fts = (
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
is not None
)
if not has_fts:
return None, None
# Convert millisecond timestamps defensively (some datasets store ms).
ts_expr = (
"CASE "
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
"ELSE CAST(create_time AS INTEGER) "
"END"
)
# NOTE: local_type=10000 are mostly system messages; exclude to make the moment nicer.
where = (
f"{ts_expr} >= ? AND {ts_expr} < ? "
"AND db_stem NOT LIKE 'biz_message%' "
"AND sender_username = ? "
"AND CAST(local_type AS INTEGER) != 10000"
)
base_sql = (
"SELECT ts, username, db_stem, table_name, CAST(local_id AS INTEGER) AS local_id, "
"CAST(strftime('%H', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS h, "
"CAST(strftime('%M', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS m, "
"CAST(strftime('%S', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS s "
"FROM ("
f" SELECT {ts_expr} AS ts, username, db_stem, table_name, local_id "
" FROM message_fts "
f" WHERE {where}"
") sub "
)
def row_to_ref(r: Any) -> Optional[_SentMomentRef]:
if not r:
return None
try:
ts = int(r[0] or 0)
except Exception:
ts = 0
username = str(r[1] or "").strip()
db_stem = str(r[2] or "").strip()
table_name = str(r[3] or "").strip()
try:
local_id = int(r[4] or 0)
except Exception:
local_id = 0
try:
h = int(r[5] or 0)
m = int(r[6] or 0)
s = int(r[7] or 0)
except Exception:
h, m, s = 0, 0, 0
if ts <= 0 or not username or not db_stem or not table_name or local_id <= 0:
return None
# Treat 00:00-04:59 as "late night": shift them +24h so they rank after 23:xx.
score = (h * 3600 + m * 60 + s) + (86400 if h < 5 else 0)
return _SentMomentRef(
ts=int(ts),
score=int(score),
username=username,
db_stem=db_stem,
table_name=table_name,
local_id=int(local_id),
)
params = (start_ts, end_ts, sender)
sql_earliest = (
base_sql
+ "ORDER BY (h*3600 + m*60 + s + CASE WHEN h < 5 THEN 86400 ELSE 0 END) ASC, ts ASC LIMIT 1"
)
sql_latest = (
base_sql
+ "ORDER BY (h*3600 + m*60 + s + CASE WHEN h < 5 THEN 86400 ELSE 0 END) DESC, ts DESC LIMIT 1"
)
earliest_ref = row_to_ref(conn.execute(sql_earliest, params).fetchone())
latest_ref = row_to_ref(conn.execute(sql_latest, params).fetchone())
return earliest_ref, latest_ref
except Exception:
return None, None
finally:
try:
conn.close()
except Exception:
pass
def _compute_sent_moment_refs_fallback(
*,
account_dir: Path,
year: int,
sender_username: str,
) -> tuple[Optional[_SentMomentRef], Optional[_SentMomentRef]]:
"""Fallback implementation when no search index is present."""
start_ts, end_ts = _year_range_epoch_seconds(year)
sender = str(sender_username or "").strip()
if not sender:
return None, None
# Resolve all sessions (usernames) so we can map msg_xxx/chat_xxx tables back to usernames.
session_usernames = _list_session_usernames(account_dir / "session.db")
md5_to_username: dict[str, str] = {}
table_to_username: dict[str, str] = {}
for u in session_usernames:
md5_hex = hashlib.md5(u.encode("utf-8")).hexdigest().lower()
md5_to_username[md5_hex] = u
table_to_username[f"msg_{md5_hex}"] = u
table_to_username[f"chat_{md5_hex}"] = u
def resolve_username_from_table(table_name: str) -> Optional[str]:
ln = str(table_name or "").lower()
u = table_to_username.get(ln)
if u:
return u
m = _MD5_HEX_RE.search(ln)
if m:
return md5_to_username.get(m.group(0).lower())
return None
db_paths = _iter_message_db_paths(account_dir)
db_paths = [p for p in db_paths if not p.name.lower().startswith("biz_message")]
ts_expr = (
"CASE WHEN create_time > 1000000000000 THEN CAST(create_time/1000 AS INTEGER) ELSE create_time END"
)
best_earliest: Optional[_SentMomentRef] = None
best_latest: Optional[_SentMomentRef] = None
for db_path in db_paths:
if not db_path.exists():
continue
conn: sqlite3.Connection | None = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
# Resolve sender rowid for this shard so we can filter sent messages.
try:
r2 = conn.execute("SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", (sender,)).fetchone()
sender_rowid = int(r2[0]) if r2 and r2[0] is not None else None
except Exception:
sender_rowid = None
if sender_rowid is None:
continue
tables = _list_message_tables(conn)
if not tables:
continue
for table_name in tables:
username = resolve_username_from_table(table_name)
if not username:
continue
qt = _quote_ident(table_name)
params = (start_ts, end_ts, int(sender_rowid))
sql_base = (
"SELECT local_id, ts, "
"CAST(strftime('%H', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS h, "
"CAST(strftime('%M', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS m, "
"CAST(strftime('%S', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS s "
"FROM ("
f" SELECT local_id, {ts_expr} AS ts "
f" FROM {qt} "
f" WHERE {ts_expr} >= ? AND {ts_expr} < ? "
" AND real_sender_id = ? "
" AND local_type != 10000"
") sub "
)
sql_earliest = (
sql_base
+ "ORDER BY (h*3600 + m*60 + s + CASE WHEN h < 5 THEN 86400 ELSE 0 END) ASC, ts ASC LIMIT 1"
)
sql_latest = (
sql_base
+ "ORDER BY (h*3600 + m*60 + s + CASE WHEN h < 5 THEN 86400 ELSE 0 END) DESC, ts DESC LIMIT 1"
)
try:
r_earliest = conn.execute(sql_earliest, params).fetchone()
except Exception:
r_earliest = None
if r_earliest:
try:
local_id = int(r_earliest["local_id"] or 0)
ts = int(r_earliest["ts"] or 0)
h = int(r_earliest["h"] or 0)
m = int(r_earliest["m"] or 0)
s = int(r_earliest["s"] or 0)
except Exception:
local_id, ts, h, m, s = 0, 0, 0, 0, 0
if local_id > 0 and ts > 0:
score = (h * 3600 + m * 60 + s) + (86400 if h < 5 else 0)
ref = _SentMomentRef(
ts=int(ts),
score=int(score),
username=str(username),
db_stem=str(db_path.stem),
table_name=str(table_name),
local_id=int(local_id),
)
if best_earliest is None or ref.score < best_earliest.score or (
ref.score == best_earliest.score and ref.ts < best_earliest.ts
):
best_earliest = ref
try:
r_latest = conn.execute(sql_latest, params).fetchone()
except Exception:
r_latest = None
if r_latest:
try:
local_id = int(r_latest["local_id"] or 0)
ts = int(r_latest["ts"] or 0)
h = int(r_latest["h"] or 0)
m = int(r_latest["m"] or 0)
s = int(r_latest["s"] or 0)
except Exception:
local_id, ts, h, m, s = 0, 0, 0, 0, 0
if local_id > 0 and ts > 0:
score = (h * 3600 + m * 60 + s) + (86400 if h < 5 else 0)
ref = _SentMomentRef(
ts=int(ts),
score=int(score),
username=str(username),
db_stem=str(db_path.stem),
table_name=str(table_name),
local_id=int(local_id),
)
if best_latest is None or ref.score > best_latest.score or (
ref.score == best_latest.score and ref.ts > best_latest.ts
):
best_latest = ref
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
return best_earliest, best_latest
def _fetch_message_moment_payload(
*,
account_dir: Path,
ref: _SentMomentRef,
contact_rows: dict[str, sqlite3.Row],
) -> Optional[dict[str, Any]]:
"""Resolve ref -> a payload for the frontend card (content is blurred in UI)."""
username = str(ref.username or "").strip()
if not username:
return None
db_path = account_dir / f"{ref.db_stem}.db"
if not db_path.exists():
return None
conn: sqlite3.Connection | None = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
my_rowid: Optional[int]
try:
r2 = conn.execute("SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", (str(account_dir.name),)).fetchone()
my_rowid = int(r2[0]) if r2 and r2[0] is not None else None
except Exception:
my_rowid = None
qt = _quote_ident(ref.table_name)
sql_with_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, n.user_name AS sender_username "
f"FROM {qt} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"WHERE m.local_id = ? LIMIT 1"
)
sql_no_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, '' AS sender_username "
f"FROM {qt} m "
"WHERE m.local_id = ? LIMIT 1"
)
try:
row = conn.execute(sql_with_join, (int(ref.local_id),)).fetchone()
except Exception:
row = None
if row is None:
try:
row = conn.execute(sql_no_join, (int(ref.local_id),)).fetchone()
except Exception:
row = None
if row is None:
return None
hit = _row_to_search_hit(
row,
db_path=db_path,
table_name=str(ref.table_name),
username=username,
account_dir=account_dir,
is_group=bool(username.endswith("@chatroom")),
my_rowid=my_rowid,
)
content = str(hit.get("content") or "").strip()
content = re.sub(r"\s+", " ", content).strip()
if len(content) > 120:
content = content[:117] + "..."
dt = datetime.fromtimestamp(int(ref.ts))
contact_row = contact_rows.get(username)
display = _pick_display_name(contact_row, username)
avatar = _pick_avatar_url(contact_row) or (_build_avatar_url(str(account_dir.name or ""), username) if username else "")
return {
"timestamp": int(ref.ts),
"date": dt.strftime("%Y-%m-%d"),
"time": dt.strftime("%H:%M"),
"username": username,
"displayName": display,
"maskedName": _mask_name(display),
"avatarUrl": avatar,
"content": content,
"renderType": str(hit.get("renderType") or ""),
"isGroup": bool(username.endswith("@chatroom")),
}
except Exception:
return None
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
def _list_message_tables(conn: sqlite3.Connection) -> list[str]: def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
try: try:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
@@ -341,6 +997,87 @@ def build_card_01_cyber_schedule(
total=heatmap.total_messages, total=heatmap.total_messages,
) )
# Earliest/latest sent message moments (best-effort).
earliest_sent = None
latest_sent = None
if heatmap.total_messages > 0:
t0 = time.time()
ref_earliest, ref_latest = _compute_sent_moment_refs_from_index(
account_dir=account_dir,
year=year,
sender_username=sender,
)
if ref_earliest is None and ref_latest is None:
ref_earliest, ref_latest = _compute_sent_moment_refs_fallback(
account_dir=account_dir,
year=year,
sender_username=sender,
)
usernames: list[str] = []
if ref_earliest and ref_earliest.username:
usernames.append(ref_earliest.username)
if ref_latest and ref_latest.username and ref_latest.username not in usernames:
usernames.append(ref_latest.username)
contact_rows = _load_contact_rows(account_dir / "contact.db", usernames) if usernames else {}
if ref_earliest is not None:
earliest_sent = _fetch_message_moment_payload(account_dir=account_dir, ref=ref_earliest, contact_rows=contact_rows)
if ref_latest is not None:
latest_sent = _fetch_message_moment_payload(account_dir=account_dir, ref=ref_latest, contact_rows=contact_rows)
logger.info(
"Wrapped card#1 moments computed: account=%s year=%s earliest=%s latest=%s elapsed=%.2fs",
str(account_dir.name or "").strip(),
year,
"ok" if earliest_sent else "none",
"ok" if latest_sent else "none",
time.time() - t0,
)
# Year's chronologically first/last sent messages (by timestamp, not time-of-day).
year_first_sent = None
year_last_sent = None
if heatmap.total_messages > 0:
t0 = time.time()
ref_first, ref_last = _compute_year_first_last_from_index(
account_dir=account_dir,
year=year,
sender_username=sender,
)
if ref_first is None and ref_last is None:
ref_first, ref_last = _compute_year_first_last_fallback(
account_dir=account_dir,
year=year,
sender_username=sender,
)
# Collect usernames for contact lookup (reuse existing contact_rows if possible).
extra_usernames: list[str] = []
if ref_first and ref_first.username:
extra_usernames.append(ref_first.username)
if ref_last and ref_last.username and ref_last.username not in extra_usernames:
extra_usernames.append(ref_last.username)
# Load contacts for new usernames not already in contact_rows.
new_usernames = [u for u in extra_usernames if u not in contact_rows]
if new_usernames:
extra_contacts = _load_contact_rows(account_dir / "contact.db", new_usernames)
contact_rows.update(extra_contacts)
if ref_first is not None:
year_first_sent = _fetch_message_moment_payload(account_dir=account_dir, ref=ref_first, contact_rows=contact_rows)
if ref_last is not None:
year_last_sent = _fetch_message_moment_payload(account_dir=account_dir, ref=ref_last, contact_rows=contact_rows)
logger.info(
"Wrapped card#1 year first/last computed: account=%s year=%s first=%s last=%s elapsed=%.2fs",
str(account_dir.name or "").strip(),
year,
"ok" if year_first_sent else "none",
"ok" if year_last_sent else "none",
time.time() - t0,
)
return { return {
"id": 1, "id": 1,
"title": "你是「早八人」还是「夜猫子」?", "title": "你是「早八人」还是「夜猫子」?",
@@ -354,5 +1091,9 @@ def build_card_01_cyber_schedule(
"hourLabels": heatmap.hour_labels, "hourLabels": heatmap.hour_labels,
"matrix": heatmap.matrix, "matrix": heatmap.matrix,
"totalMessages": heatmap.total_messages, "totalMessages": heatmap.total_messages,
"earliestSent": earliest_sent,
"latestSent": latest_sent,
"yearFirstSent": year_first_sent,
"yearLastSent": year_last_sent,
}, },
} }

View File

@@ -23,7 +23,7 @@ logger = get_logger(__name__)
# an older partial cache. # an older partial cache.
_IMPLEMENTED_UPTO_ID = 2 _IMPLEMENTED_UPTO_ID = 2
# Bump this when we change card payloads/ordering while keeping the same implemented_upto. # Bump this when we change card payloads/ordering while keeping the same implemented_upto.
_CACHE_VERSION = 4 _CACHE_VERSION = 5
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card. # "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.