501 lines
19 KiB
Plaintext
501 lines
19 KiB
Plaintext
/**
|
||
* 家书 FamilyBook · **日程**桌面卡片数据(鸿蒙端)
|
||
*
|
||
* 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」
|
||
* ⇒ 结构照 `D:\Yongquan\code\SyncCalendar` 的 `CardDataService` + 四块卡:
|
||
* · 2×2 日期 / 星期农历 / 当天条数;
|
||
* · 2×4 左边同 2×2,右边「今天接下来两条」;
|
||
* · 4×4 日期翻页条 + 当天日程(时间轴 / 列表,跟应用内设置联动);
|
||
* · 6×4 同 4×4,横向更宽。
|
||
*
|
||
* 与 SyncCalendar 的**两处有意差异**(都是家书的数据形状决定的,不照抄):
|
||
* ① 它读端上本地库,家书读服务端 `GET /api/v1/calendar?from=&to=`;
|
||
* ② 它的数据里没有"衍生条目",家书的 `/calendar` 会给农历 / 节气 / 节假日
|
||
* ⇒ 农历行抽成 `lunarText`(抬头那一行),其余衍生条目照常进列表。
|
||
*
|
||
* 🔴 三条硬约束(沿用上一版卡片,别丢):
|
||
* ① 卡片进程生命周期回调**只活约 10 秒** ⇒ 回调里不同步做长任务;
|
||
* ② `FormBindingData` **只认标量** ⇒ 数组一律 `JSON.stringify` 成字符串;
|
||
* ③ 卡片是**后台**刷新,请求必须 `authProbe: true` 且显式带 token ——
|
||
* 否则一次 401 会触发全局"踢回登录页",用户在应用里用得好好的却被卡片踢下线。
|
||
*/
|
||
|
||
import { common } from '@kit.AbilityKit';
|
||
import { preferences } from '@kit.ArkData';
|
||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||
import { BusinessError } from '@kit.BasicServicesKit';
|
||
import { FB_SERVER_STORE } from './FbServerStore';
|
||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||
import { FB_CAL_SETTINGS } from './FbCalSettings';
|
||
import { fbApi, FbApiResult } from './FbHttp';
|
||
import { parseCalendar } from './FbParse';
|
||
import { FbCalEvent } from '../model/Types';
|
||
import { FbTimelineUtil, FbTimelineData, FbTimeBlock, FbViewRange } from '../common/FbTimeline';
|
||
|
||
const DOMAIN: number = 0x0000;
|
||
const TAG: string = 'FbCard';
|
||
|
||
/** 卡片上的一行日程(扁平标量,卡片侧 `JSON.parse` 后 ForEach) */
|
||
export class FbCardRow {
|
||
/** ForEach 的 key(**必须唯一**:同名两条只渲染一条,踩过) */
|
||
key: string = '';
|
||
/** `09:00` / `全天` */
|
||
time: string = '';
|
||
/** `10:30`(全天为空) */
|
||
endTime: string = '';
|
||
title: string = '';
|
||
/** 所属日历本色标(左侧色条) */
|
||
color: string = '#A84A28';
|
||
/** 所属日历本名(4×4 列表右侧小字) */
|
||
calName: string = '';
|
||
/** 正在进行中(仅"今天"的有时间日程) */
|
||
isNow: boolean = false;
|
||
}
|
||
|
||
/** 时间轴上的一块(比例形式;视图侧按小时高度换算成 vp) */
|
||
export class FbCardBlock {
|
||
key: string = '';
|
||
title: string = '';
|
||
timeText: string = '';
|
||
color: string = '#A84A28';
|
||
/** 距 0 点的比例 0~1 */
|
||
topRatio: number = 0;
|
||
heightRatio: number = 0;
|
||
/** 冲突组序号(同一簇重叠日程) */
|
||
groupIndex: number = 0;
|
||
/** 组内列号(同列互不重叠) */
|
||
laneIndex: number = 0;
|
||
isNow: boolean = false;
|
||
}
|
||
|
||
/** 时间轴渲染的一"行":要么是一段空白,要么是一个冲突组(组内按列并排) */
|
||
export class FbCardTlBlock {
|
||
key: string = '';
|
||
title: string = '';
|
||
timeText: string = '';
|
||
color: string = '#A84A28';
|
||
isNow: boolean = false;
|
||
/** 组内上方的空白高度(vp) */
|
||
padTopVp: number = 0;
|
||
/** 色块高度(vp) */
|
||
heightVp: number = 0;
|
||
}
|
||
|
||
export class FbCardTlGroup {
|
||
key: string = '';
|
||
heightVp: number = 0;
|
||
lanes: FbCardTlBlock[][] = [];
|
||
}
|
||
|
||
export class FbCardTlLine {
|
||
key: string = '';
|
||
/** 空白行高度(kind=0 时有效) */
|
||
blankVp: number = 0;
|
||
/** kind=1 时有效 */
|
||
group: FbCardTlGroup | null = null;
|
||
}
|
||
|
||
/**
|
||
* 卡片整体数据。
|
||
*
|
||
* ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`),
|
||
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",编译不报错。
|
||
*/
|
||
export class FbCardData {
|
||
/** `ok` / `login` / `noserver` / `offline` / `loading` */
|
||
state: string = 'loading';
|
||
/** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */
|
||
hint: string = '';
|
||
/** `15:40 更新` */
|
||
updatedAt: string = '';
|
||
/** `10月20日` */
|
||
dateMd: string = '';
|
||
/** `今天 10月20日 周二` */
|
||
dateText: string = '';
|
||
/** `星期二` */
|
||
weekday: string = '';
|
||
/** `九月初一`(空 = 算不出 / 那天没有农历行,卡片不画) */
|
||
lunarText: string = '';
|
||
/** 相对今天的天数偏移(0 = 今天) */
|
||
dayOffset: number = 0;
|
||
/** 当前显示的是不是今天(只有今天画"当前时间"红线) */
|
||
isToday: boolean = true;
|
||
/** 当前显示那天 0 点的毫秒(点卡片进应用时定位到这一天) */
|
||
selectedDate: number = 0;
|
||
/** 当天条目数(含全天) */
|
||
dayCount: number = 0;
|
||
/** 当天日程(行式,全天在前、其余按时间升序) */
|
||
listJson: string = '[]';
|
||
/** 全天/跨天条目(单独一组,画在时间轴上方) */
|
||
allDayJson: string = '[]';
|
||
/** 时间轴色块(`FbCardBlock[]`) */
|
||
timelineJson: string = '[]';
|
||
/** 「接下来两条」(2×4 右半用) */
|
||
upcomingJson: string = '[]';
|
||
/** 日程画法:`timeline` / `list`(与应用内「日程设置」联动) */
|
||
displayStyle: string = 'timeline';
|
||
/** 时间轴视窗(小时,含起点不含终点) */
|
||
viewStartHour: number = 8;
|
||
viewEndHour: number = 18;
|
||
/** 当前时刻在当天时间轴上的比例;**-1 = 不画红线**(非今天) */
|
||
nowRatio: number = -1;
|
||
/** `15:40` */
|
||
nowLabel: string = '';
|
||
/** 🔴 v89:本次推送的唯一版本号(`Date.now()`)。
|
||
* ⚠️ 卡片 ForEach 的 key **必须**带上前缀:时间轴的冲突组号/空白 key
|
||
* (`g0`、`gap0`)每份数据都从 0 重新编号 —— 翻到下一天时 key 一模一样,
|
||
* ArkUI ForEach 认为"没变"直接复用旧组件 ⇒ 色块/空白高度全是旧一天的
|
||
* (真机实测:10/25、10/26 一直显示 10/24 的「工程管理硕士开题」)。
|
||
* 日期头、全天区能更新是因为它们的 key(事件 id)确实变了。
|
||
* ⚠️ 用 `Date.now()` 而不是 dayOffset:同一天也可能被定时刷新推新数据
|
||
* (日程改了),version 必须每次推送都不同才能逼 ForEach 全量重建。 */
|
||
version: string = '';
|
||
}
|
||
|
||
/** 两位补零 */
|
||
function pad2(v: number): string {
|
||
return v < 10 ? '0' + v.toString() : v.toString();
|
||
}
|
||
|
||
/** 当前时刻 `HH:mm` */
|
||
function clockText(): string {
|
||
const d: Date = new Date();
|
||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||
}
|
||
|
||
const FULL_WEEK: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||
const SHORT_WEEK: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
|
||
/** `Date` → `YYYY-MM-DD`(**本地时区**:服务端也按本地自然日给条目) */
|
||
function isoOf(d: Date): string {
|
||
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
缓存(断网时显示上次成功取到的数据)
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
const CACHE_STORE: string = 'fb_card';
|
||
const CACHE_PREFIX: string = 'd_';
|
||
|
||
async function saveCache(ctx: common.Context, iso: string, payload: string): Promise<void> {
|
||
try {
|
||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||
await store.put(CACHE_PREFIX + iso, payload);
|
||
// ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写
|
||
await store.flush();
|
||
} catch (err) {
|
||
const e: BusinessError = err as BusinessError;
|
||
hilog.warn(DOMAIN, TAG, '卡片缓存写入失败:%{public}s', e.message);
|
||
}
|
||
}
|
||
|
||
async function loadCache(ctx: common.Context, iso: string): Promise<string> {
|
||
try {
|
||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||
const v: preferences.ValueType = await store.get(CACHE_PREFIX + iso, '');
|
||
return typeof v === 'string' ? v : '';
|
||
} catch (err) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════
|
||
组装
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
/** 这一天第几分钟(`HH:mm` → 分钟;认不出返回 -1) */
|
||
function minutesOf(hhmm: string): number {
|
||
return FbTimelineUtil.minutesOf(hhmm);
|
||
}
|
||
|
||
/** 一条日程 → 卡片行 */
|
||
function rowOf(e: FbCalEvent, idx: number, nowMin: number, isToday: boolean): FbCardRow {
|
||
const r: FbCardRow = new FbCardRow();
|
||
r.key = 'r' + idx.toString() + '_' + e.kind + '_' + e.id.toString() + '_' + e.time;
|
||
const allDay: boolean = e.allDay || minutesOf(e.time) < 0;
|
||
r.time = allDay ? '全天' : e.time;
|
||
r.endTime = allDay ? '' : e.endTime;
|
||
// 标题为空时用日历本名兜底(农历/节气这类衍生条目),别显示"(无标题)"
|
||
r.title = e.title !== '' ? e.title : (e.calName !== '' ? e.calName : '未命名日程');
|
||
r.color = e.color;
|
||
r.calName = e.calName;
|
||
if (!allDay && isToday) {
|
||
const s: number = minutesOf(e.time);
|
||
let en: number = minutesOf(e.endTime);
|
||
if (en <= s) {
|
||
en = s + 60;
|
||
}
|
||
r.isNow = nowMin >= s && nowMin < en;
|
||
}
|
||
return r;
|
||
}
|
||
|
||
/** 全天在前、其余按开始时间升序(时间相同按标题,保证顺序稳定) */
|
||
function byDayOrder(a: FbCalEvent, b: FbCalEvent): number {
|
||
const sa: number = a.allDay || minutesOf(a.time) < 0 ? -1 : minutesOf(a.time);
|
||
const sb: number = b.allDay || minutesOf(b.time) < 0 ? -1 : minutesOf(b.time);
|
||
if (sa !== sb) {
|
||
return sa - sb;
|
||
}
|
||
return a.title.localeCompare(b.title);
|
||
}
|
||
|
||
/** 把接口行铺进卡片数据 */
|
||
function applyRows(rows: FbCalEvent[], out: FbCardData, now: Date): void {
|
||
const nowMin: number = now.getHours() * 60 + now.getMinutes();
|
||
|
||
// 1) 农历行 → 抬头那行小字(它的信息量就在"九月初一"这几个字,不必再占一行列表)
|
||
const events: FbCalEvent[] = [];
|
||
for (const e of rows) {
|
||
if (e.kind === 'lunar') {
|
||
if (out.lunarText === '') {
|
||
out.lunarText = e.title;
|
||
}
|
||
} else {
|
||
events.push(e);
|
||
}
|
||
}
|
||
events.sort(byDayOrder);
|
||
|
||
// 2) 行式列表(4×4 列表模式 / 6×4)
|
||
const list: FbCardRow[] = [];
|
||
const allDay: FbCardRow[] = [];
|
||
let idx: number = 0;
|
||
for (const e of events) {
|
||
const r: FbCardRow = rowOf(e, idx, nowMin, out.isToday);
|
||
list.push(r);
|
||
if (r.time === '全天') {
|
||
allDay.push(r);
|
||
}
|
||
idx++;
|
||
}
|
||
out.listJson = JSON.stringify(list);
|
||
out.allDayJson = JSON.stringify(allDay);
|
||
out.dayCount = list.length;
|
||
|
||
// 3) 时间轴(只放"带时间"的条目;全天另画在顶部 —— 与 `FbTimelineUtil` 的约定一致)
|
||
const tl: FbTimelineData = FbTimelineUtil.build(events, out.isToday, nowMin);
|
||
const blocks: FbCardBlock[] = [];
|
||
for (const b of tl.blocks as FbTimeBlock[]) {
|
||
const cb: FbCardBlock = new FbCardBlock();
|
||
cb.key = b.key;
|
||
cb.title = b.title;
|
||
cb.timeText = b.timeText;
|
||
cb.color = b.color;
|
||
cb.topRatio = b.topRatio;
|
||
cb.heightRatio = b.heightRatio;
|
||
cb.groupIndex = b.groupIndex;
|
||
cb.laneIndex = b.laneIndex;
|
||
cb.isNow = b.isNow;
|
||
blocks.push(cb);
|
||
}
|
||
out.timelineJson = JSON.stringify(blocks);
|
||
const range: FbViewRange = FbTimelineUtil.viewRange(tl.blocks);
|
||
out.viewStartHour = range.startHour;
|
||
out.viewEndHour = range.endHour;
|
||
|
||
// 4) 「接下来两条」(2×4 右半):今天取"还没开始的",别的日期取当天头两条
|
||
const upcoming: FbCardRow[] = [];
|
||
for (const r of list) {
|
||
if (upcoming.length >= 2) {
|
||
break;
|
||
}
|
||
if (r.time === '全天') {
|
||
continue;
|
||
}
|
||
if (out.isToday && minutesOf(r.time) < nowMin) {
|
||
continue;
|
||
}
|
||
upcoming.push(r);
|
||
}
|
||
// 今天已经没有"还没开始的"了 ⇒ 退回头两条有时间的(别让右半空着说"今日日程已结束")
|
||
if (upcoming.length === 0) {
|
||
for (const r of list) {
|
||
if (upcoming.length >= 2) {
|
||
break;
|
||
}
|
||
if (r.time !== '全天') {
|
||
upcoming.push(r);
|
||
}
|
||
}
|
||
}
|
||
out.upcomingJson = JSON.stringify(upcoming);
|
||
}
|
||
|
||
/**
|
||
* 组装一份卡片数据(异步)。
|
||
*
|
||
* @param dayOffset 相对今天的偏移(卡片翻页:负=过去,0=今天,正=未来)
|
||
*
|
||
* ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。
|
||
*/
|
||
export async function fbCardBuild(ctx: common.Context, dayOffset: number): Promise<FbCardData> {
|
||
const out: FbCardData = new FbCardData();
|
||
const now: Date = new Date();
|
||
out.updatedAt = clockText();
|
||
out.nowLabel = out.updatedAt;
|
||
out.dayOffset = dayOffset;
|
||
/* 🔴 v89:每次推送一个新版本号,卡片侧 ForEach key 全部带上前缀 ——
|
||
不带的话翻页/刷新时 key 撞车,时间轴整层不重绘(文件头 version 注释)。 */
|
||
out.version = Date.now().toString();
|
||
out.isToday = dayOffset === 0;
|
||
out.nowRatio = out.isToday ? (now.getHours() * 60 + now.getMinutes()) / 1440 : -1;
|
||
|
||
const base: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset);
|
||
out.selectedDate = base.getTime();
|
||
out.dateMd = (base.getMonth() + 1).toString() + '月' + base.getDate().toString() + '日';
|
||
out.weekday = FULL_WEEK[base.getDay()];
|
||
const prefix: string = dayOffset === 0 ? '今天 ' : (dayOffset === 1 ? '明天 ' : (dayOffset === -1 ? '昨天 ' : ''));
|
||
out.dateText = prefix + out.dateMd + ' ' + SHORT_WEEK[base.getDay()];
|
||
|
||
try {
|
||
await FB_SERVER_STORE.init(ctx);
|
||
await FB_TOKEN_STORE.init(ctx);
|
||
// 显示方式(时间轴 / 列表)与应用内设置联动 —— 与 SyncCalendar 的 displayStyle 同一个思路
|
||
await FB_CAL_SETTINGS.init(ctx);
|
||
out.displayStyle = FB_CAL_SETTINGS.getStyle();
|
||
} catch (err) {
|
||
hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err));
|
||
}
|
||
if (FB_SERVER_STORE.get() === '') {
|
||
out.state = 'noserver';
|
||
out.hint = '先在「家书」里填写服务器地址';
|
||
return out;
|
||
}
|
||
const token: string = FB_TOKEN_STORE.get();
|
||
if (token === '') {
|
||
out.state = 'login';
|
||
out.hint = '先在「家书」里登录';
|
||
return out;
|
||
}
|
||
|
||
const iso: string = isoOf(base);
|
||
/* 只查**这一天**(服务端的 `from`/`to` 都是闭区间)。
|
||
⚠️ 查询参数要显式声明成 `Record<string, string>` 再赋值:
|
||
ArkTS 不允许"字面量直接塞进 `Record`",会报 arkts-no-untyped-obj-literals。 */
|
||
const query: Record<string, string> = {};
|
||
query['from'] = iso;
|
||
query['to'] = iso;
|
||
/* `authProbe: true` ⇒ 401 时**不触发全局"踢回登录页"**(见文件头约束 ③)。
|
||
⚠️ 代价:它会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */
|
||
const r: FbApiResult = await fbApi('/api/v1/calendar', {
|
||
token: token,
|
||
authProbe: true,
|
||
query: query
|
||
});
|
||
if (r.ok) {
|
||
applyRows(parseCalendar(r.data), out, now);
|
||
out.state = 'ok';
|
||
out.hint = '';
|
||
await saveCache(ctx, iso, r.data);
|
||
return out;
|
||
}
|
||
const cached: string = await loadCache(ctx, iso);
|
||
if (cached !== '') {
|
||
applyRows(parseCalendar(cached), out, now);
|
||
out.state = 'offline';
|
||
out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据';
|
||
return out;
|
||
}
|
||
out.state = 'offline';
|
||
out.hint = r.status === 401 ? '登录已失效,请重新登录'
|
||
: (r.error !== '' ? r.error : '连不上服务器');
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 时间轴「行」列表:把比例形式的色块换算成 vp 高度,供视图直接 `ForEach`。
|
||
*
|
||
* 为什么不在卡片里算:卡片页的 ArkTS 是受限子集、也不好写测试;
|
||
* 这里纯函数、只吃标量,守卫与反向验证都能直接盯。
|
||
*
|
||
* @param blocks `FbCardBlock[]`(已按 topRatio 升序)
|
||
* @param startHour 视窗起点小时
|
||
* @param hourVp 1 小时 = 多少 vp
|
||
*/
|
||
export function fbCardTlLines(blocks: FbCardBlock[], startHour: number, hourVp: number): FbCardTlLine[] {
|
||
const lines: FbCardTlLine[] = [];
|
||
// 1) 按冲突组归并(组号在 `FbTimelineUtil.build` 里已算好)
|
||
const order: number[] = [];
|
||
const groups: Map<number, FbCardBlock[]> = new Map<number, FbCardBlock[]>();
|
||
for (const b of blocks) {
|
||
const arr: FbCardBlock[] | undefined = groups.get(b.groupIndex);
|
||
if (arr === undefined) {
|
||
groups.set(b.groupIndex, [b]);
|
||
order.push(b.groupIndex);
|
||
} else {
|
||
arr.push(b);
|
||
}
|
||
}
|
||
order.sort((a: number, b: number): number => a - b);
|
||
|
||
// 2) 逐组产出:组前补空白 + 组内按列并排
|
||
// ⚠️ 游标从**视窗起点**开始(不是 0 点):8 点视窗下第一组前面的 0~8 点空白
|
||
// 不该画进卡片,否则时间轴一开始就有一大段空格子,看着像"数据算错了"。
|
||
let cursor: number = startHour; // 已画到"当天第几小时"(浮点)
|
||
for (const gi of order) {
|
||
const bs: FbCardBlock[] = groups.get(gi) ?? [];
|
||
if (bs.length === 0) {
|
||
continue;
|
||
}
|
||
let top: number = 1;
|
||
let bot: number = 0;
|
||
for (const b of bs) {
|
||
const t: number = b.topRatio * 24;
|
||
const e: number = (b.topRatio + b.heightRatio) * 24;
|
||
if (t < top) {
|
||
top = t;
|
||
}
|
||
if (e > bot) {
|
||
bot = e;
|
||
}
|
||
}
|
||
if (top < cursor) {
|
||
top = cursor;
|
||
}
|
||
const gap: number = (top - cursor) * hourVp;
|
||
if (gap > 0.5) {
|
||
const blank: FbCardTlLine = new FbCardTlLine();
|
||
blank.key = 'gap' + gi.toString();
|
||
blank.blankVp = gap;
|
||
lines.push(blank);
|
||
}
|
||
const g: FbCardTlGroup = new FbCardTlGroup();
|
||
g.key = 'g' + gi.toString();
|
||
g.heightVp = Math.max((bot - top) * hourVp, 14);
|
||
// 列号可能不连续 ⇒ 用"出现的列号升序"重排,保证 Row 内 layoutWeight 均匀
|
||
const laneIdx: number[] = [];
|
||
for (const b of bs) {
|
||
if (laneIdx.indexOf(b.laneIndex) < 0) {
|
||
laneIdx.push(b.laneIndex);
|
||
}
|
||
}
|
||
laneIdx.sort((a: number, b: number): number => a - b);
|
||
for (const li of laneIdx) {
|
||
const lane: FbCardTlBlock[] = [];
|
||
for (const b of bs) {
|
||
if (b.laneIndex !== li) {
|
||
continue;
|
||
}
|
||
const cb: FbCardTlBlock = new FbCardTlBlock();
|
||
cb.key = b.key;
|
||
cb.title = b.title;
|
||
cb.timeText = b.timeText;
|
||
cb.color = b.color;
|
||
cb.isNow = b.isNow;
|
||
cb.padTopVp = Math.max((b.topRatio * 24 - top) * hourVp, 0);
|
||
cb.heightVp = Math.max(b.heightRatio * 24 * hourVp, 14);
|
||
lane.push(cb);
|
||
}
|
||
g.lanes.push(lane);
|
||
}
|
||
const line: FbCardTlLine = new FbCardTlLine();
|
||
line.key = g.key;
|
||
line.group = g;
|
||
lines.push(line);
|
||
cursor = bot;
|
||
}
|
||
return lines;
|
||
}
|