mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
feat(wrapped): 添加最早最晚消息展示功能
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))]">
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user